@adonis-agora/authkit-server 1.0.0-rc.11 → 1.0.0-rc.13

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/build/index.d.ts +2 -1
  4. package/build/index.js +1 -0
  5. package/build/providers/authkit_server_provider.js +15 -4
  6. package/build/src/accounts/lucid_account_store.js +4 -3
  7. package/build/src/accounts/lucid_store/mfa.js +1 -1
  8. package/build/src/accounts/lucid_store/organization_models.d.ts +9 -5
  9. package/build/src/accounts/lucid_store/organization_models.js +16 -12
  10. package/build/src/accounts/lucid_store/password_hygiene.js +4 -2
  11. package/build/src/accounts/lucid_store/shared.d.ts +6 -1
  12. package/build/src/accounts/lucid_store/shared.js +7 -6
  13. package/build/src/accounts/lucid_store/webauthn.js +1 -1
  14. package/build/src/adapters/database_adapter.d.ts +7 -1
  15. package/build/src/adapters/database_adapter.js +24 -13
  16. package/build/src/adapters/factory.d.ts +7 -0
  17. package/build/src/adapters/factory.js +6 -2
  18. package/build/src/agents/delegation_store.d.ts +5 -1
  19. package/build/src/agents/delegation_store.js +34 -21
  20. package/build/src/agents/extension.d.ts +16 -2
  21. package/build/src/agents/registry_store.d.ts +1 -0
  22. package/build/src/agents/registry_store.js +10 -8
  23. package/build/src/agents/runtime.js +6 -2
  24. package/build/src/commands/expire_scan_command.js +2 -3
  25. package/build/src/commands/settings_commands.js +4 -4
  26. package/build/src/define_config.d.ts +8 -0
  27. package/build/src/define_config.js +23 -6
  28. package/build/src/doctor/agora_checks.js +13 -0
  29. package/build/src/doctor/checks.d.ts +31 -1
  30. package/build/src/doctor/checks.js +118 -3
  31. package/build/src/doctor/db_schema_check.d.ts +6 -0
  32. package/build/src/doctor/db_schema_check.js +87 -0
  33. package/build/src/doctor/doctor_input.js +2 -0
  34. package/build/src/host/admin_sessions_service.js +6 -2
  35. package/build/src/host/csrf.d.ts +3 -0
  36. package/build/src/host/csrf.js +27 -7
  37. package/build/src/host/runtime_settings.d.ts +15 -0
  38. package/build/src/host/runtime_settings.js +27 -12
  39. package/build/src/keys/keystore_manager.js +2 -1
  40. package/build/src/keys/keystore_vault.d.ts +9 -1
  41. package/build/src/keys/keystore_vault.js +27 -9
  42. package/build/src/schema/ensure.d.ts +9 -0
  43. package/build/src/schema/ensure.js +46 -19
  44. package/build/src/schema/tables_location.d.ts +31 -2
  45. package/build/src/schema/tables_location.js +64 -0
  46. package/package.json +3 -3
  47. package/skills/authkit-idp-setup/SKILL.md +13 -7
@@ -23,7 +23,7 @@
23
23
  * }
24
24
  * ```
25
25
  */
26
- import { authkitTablesClient } from './tables_location.js';
26
+ import { assertSchemaSupported, authkitTable, authkitTablesClient, createDbSchema, validateSchemaName, } from './tables_location.js';
27
27
  /** Busca por conta (`account_id`) e limpeza dos expirados (`expires_at`). */
28
28
  const OIDC_PAYLOAD_INDEXES = [
29
29
  { name: 'authkit_oidc_payloads_account_idx', columns: ['model_name', 'account_id'] },
@@ -139,12 +139,12 @@ const TABLES = [
139
139
  },
140
140
  {
141
141
  name: 'auth_organization_members',
142
- create: (t) => {
142
+ create: (t, ctx) => {
143
143
  t.string('id').primary();
144
144
  t.string('organization_id')
145
145
  .notNullable()
146
146
  .references('id')
147
- .inTable('auth_organizations')
147
+ .inTable(ctx.table('auth_organizations'))
148
148
  .onDelete('CASCADE');
149
149
  t.string('account_id').notNullable();
150
150
  t.string('role').notNullable().defaultTo('member');
@@ -186,12 +186,12 @@ const TABLES = [
186
186
  },
187
187
  {
188
188
  name: 'auth_organization_invitations',
189
- create: (t) => {
189
+ create: (t, ctx) => {
190
190
  t.string('id').primary();
191
191
  t.string('organization_id')
192
192
  .notNullable()
193
193
  .references('id')
194
- .inTable('auth_organizations')
194
+ .inTable(ctx.table('auth_organizations'))
195
195
  .onDelete('CASCADE');
196
196
  t.string('email').notNullable();
197
197
  t.string('role').notNullable().defaultTo('member');
@@ -335,6 +335,8 @@ const TABLES = [
335
335
  },
336
336
  },
337
337
  ];
338
+ /** Os nomes (sem schema) das tabelas que {@link ensureAuthkitSchema} garante. */
339
+ export const AUTHKIT_TABLE_NAMES = TABLES.map((t) => t.name);
338
340
  /** Linhas de um `rawQuery`, no formato de cada driver (pg, mysql2, sqlite). */
339
341
  function catalogRows(result) {
340
342
  if (Array.isArray(result))
@@ -420,7 +422,10 @@ async function columnExists(conn, table, column) {
420
422
  async function indexExists(conn, table, name) {
421
423
  const dialect = conn.dialect?.name;
422
424
  if (dialect === 'postgres' || dialect === 'redshift') {
423
- const found = catalogRows(await conn.rawQuery('select to_regclass(?) as oid', [name]));
425
+ // Índice mora no schema da tabela: com schema explícito, o nome vai qualificado.
426
+ const [schema] = mysqlRelation(table);
427
+ const relation = pgRelation(schema ? `${schema}.${name}` : name);
428
+ const found = catalogRows(await conn.rawQuery('select to_regclass(?) as oid', [relation]));
424
429
  return found[0]?.oid != null;
425
430
  }
426
431
  if (dialect === 'mysql') {
@@ -441,11 +446,12 @@ async function indexExists(conn, table, name) {
441
446
  * Cria o índice quando falta. Idempotente: um índice já existente (inclusive
442
447
  * criado por outra instância no mesmo boot) não é erro.
443
448
  */
444
- async function ensureIndex(conn, table, index) {
449
+ async function ensureIndex(conn, loc, name, index) {
450
+ const table = authkitTable(loc, name);
445
451
  if ((await indexExists(conn, table, index.name)) === true)
446
452
  return false;
447
453
  try {
448
- await conn.schema.alterTable(table, (t) => t.index(index.columns, index.name));
454
+ await ddl(conn, loc).alterTable(name, (t) => t.index(index.columns, index.name));
449
455
  return true;
450
456
  }
451
457
  catch (error) {
@@ -458,12 +464,23 @@ async function ensureIndex(conn, table, index) {
458
464
  throw error;
459
465
  }
460
466
  }
467
+ /**
468
+ * O schema builder das tabelas do authkit: com `schema`, `withSchema` — o knex escreve
469
+ * `"schema"."tabela"` em todo DDL e mantém os nomes de índice derivados da tabela pura.
470
+ */
471
+ function ddl(conn, loc) {
472
+ return loc.schema ? conn.schema.withSchema(loc.schema) : conn.schema;
473
+ }
461
474
  /** Cria/completa as tabelas (o miolo de {@link ensureAuthkitSchema}). */
462
- async function ensureTables(conn, tables, report) {
475
+ async function ensureTables(conn, loc, tables, report) {
476
+ const context = { table: (name) => authkitTable(loc, name) };
477
+ if (loc.schema)
478
+ await createDbSchema(conn, loc.schema);
463
479
  for (const def of tables) {
464
- if (!(await tableExists(conn, def.name))) {
480
+ const table = authkitTable(loc, def.name);
481
+ if (!(await tableExists(conn, table))) {
465
482
  try {
466
- await conn.schema.createTable(def.name, def.create);
483
+ await ddl(conn, loc).createTable(def.name, (t) => def.create(t, context));
467
484
  report.created.push(def.name);
468
485
  continue;
469
486
  }
@@ -472,20 +489,20 @@ async function ensureTables(conn, tables, report) {
472
489
  * Corrida entre instâncias subindo juntas: se outra instância criou
473
490
  * a tabela entre o probe e o CREATE, segue o jogo; senão, propaga.
474
491
  */
475
- if (!(await tableExists(conn, def.name)))
492
+ if (!(await tableExists(conn, table)))
476
493
  throw error;
477
494
  }
478
495
  }
479
496
  for (const [column, add] of Object.entries(def.columns)) {
480
- if (await columnExists(conn, def.name, column))
497
+ if (await columnExists(conn, table, column))
481
498
  continue;
482
- await conn.schema.alterTable(def.name, (t) => add(t));
499
+ await ddl(conn, loc).alterTable(def.name, (t) => add(t));
483
500
  if (!report.altered[def.name])
484
501
  report.altered[def.name] = [];
485
502
  report.altered[def.name].push(column);
486
503
  }
487
504
  for (const index of def.indexes ?? []) {
488
- if (!(await ensureIndex(conn, def.name, index)))
505
+ if (!(await ensureIndex(conn, loc, def.name, index)))
489
506
  continue;
490
507
  if (!report.indexed)
491
508
  report.indexed = {};
@@ -495,18 +512,26 @@ async function ensureTables(conn, tables, report) {
495
512
  }
496
513
  }
497
514
  }
515
+ function resolveLocation(options) {
516
+ return {
517
+ connection: options.connection,
518
+ schema: validateSchemaName(options.schema, 'schema'),
519
+ };
520
+ }
498
521
  /**
499
522
  * Garante tabelas lib-owned no formato {@link AuthkitTable} — o que `ensureAuthkitSchema` faz com as
500
523
  * do authkit: cria as que faltam, adiciona colunas e índices novos, nunca dropa nem altera. Para
501
524
  * uma extension expor o próprio `ensure*Schema()` para migrations do host. Estável no 1.x.
502
525
  */
503
526
  export async function ensureAuthkitTables(db, tables, options = {}) {
504
- const conn = authkitTablesClient(db, { connection: options.connection });
527
+ const loc = resolveLocation(options);
528
+ const conn = authkitTablesClient(db, loc);
529
+ assertSchemaSupported(conn, loc.schema);
505
530
  const report = {
506
531
  created: [],
507
532
  altered: {},
508
533
  };
509
- await ensureTables(conn, tables, report);
534
+ await ensureTables(conn, loc, tables, report);
510
535
  return report;
511
536
  }
512
537
  /**
@@ -518,13 +543,15 @@ export async function ensureAuthkitTables(db, tables, options = {}) {
518
543
  * (`this.db`, um `QueryClient`/`TransactionClient`, usado como está).
519
544
  */
520
545
  export async function ensureAuthkitSchema(db, options = {}) {
521
- const conn = authkitTablesClient(db, { connection: options.connection });
546
+ const loc = resolveLocation(options);
547
+ const conn = authkitTablesClient(db, loc);
548
+ assertSchemaSupported(conn, loc.schema);
522
549
  const report = {
523
550
  created: [],
524
551
  altered: {},
525
552
  loginMethods: { table: options.accountTable ?? 'users', ensured: false },
526
553
  };
527
- await ensureTables(conn, [...TABLES, ...(options.tables ?? [])], report);
554
+ await ensureTables(conn, loc, [...TABLES, ...(options.tables ?? [])], report);
528
555
  // Coluna `login_methods` na tabela da CONTA — a tabela é HOST-owned (não está
529
556
  // em TABLES porque o nome/shape são decisão do app), mas a LIB consome a
530
557
  // coluna (LoginMethodsPreferenceCapability). Alguns deploys de host só migram
@@ -4,19 +4,48 @@
4
4
  * `authkit_keystore` of the Lucid keystore, and the tables of agent extensions).
5
5
  *
6
6
  * The rule: every lib-owned table goes through `schema.connection` of `defineConfig`
7
- * (default: the primary Lucid connection). The app's account table is not lib-owned: it
8
- * stays on the account model's connection (`static connection` of the model).
7
+ * (default: the primary Lucid connection) and, with `schema.name`, lives in that database
8
+ * schema (MySQL: that database). The app's account table is not lib-owned: it stays on the
9
+ * account model's connection and table (`static connection`/`static table` of the model).
10
+ *
11
+ * The schema is written INTO every statement (`"auth"."auth_settings"`). Never put
12
+ * AuthKit's tables in another schema with a connection `searchPath`: knex sends
13
+ * `SET search_path` on the session, and behind PgBouncer in transaction mode that leaks to
14
+ * other clients' connections.
9
15
  */
10
16
  export interface AuthkitTablesLocation {
11
17
  /** Lucid connection name. `undefined` = the primary connection. */
12
18
  connection?: string;
19
+ /** Database schema of the tables (MySQL: the database). `undefined` = the connection's default. */
20
+ schema?: string;
21
+ }
22
+ /** Raised for a `schema.name` the database cannot use. The provider fails the boot with it. */
23
+ export declare class AuthkitSchemaError extends Error {
24
+ constructor(message: string);
13
25
  }
26
+ /** Throws {@link AuthkitSchemaError} unless `name` is a plain identifier of at most 63 chars. */
27
+ export declare function validateSchemaName(name: unknown, option?: string): string | undefined;
28
+ /** `schema.table` when a schema is set (knex quotes each part: `"schema"."table"`), else `table`. */
29
+ export declare function authkitTable(location: AuthkitTablesLocation | undefined, table: string): string;
14
30
  /**
15
31
  * The query client for AuthKit's tables. `db` is the Lucid manager (`Database`) or a
16
32
  * client the caller already holds (a migration's `this.db`, a transaction), which is
17
33
  * used as is.
18
34
  */
19
35
  export declare function authkitTablesClient(db: any, location?: AuthkitTablesLocation): any;
36
+ /** The Lucid dialect name of a client or manager (`postgres`, `mysql`, `better-sqlite3`…). */
37
+ export declare function dialectOf(client: any): string | undefined;
38
+ /**
39
+ * Throws {@link AuthkitSchemaError} when `schema` is set on a dialect AuthKit cannot use it on:
40
+ * SQLite (no schemas) and any dialect other than Postgres and MySQL. AuthKit never ignores the
41
+ * option nor falls back to unqualified tables.
42
+ */
43
+ export declare function assertSchemaSupported(client: any, schema: string | undefined): void;
44
+ /**
45
+ * `CREATE SCHEMA IF NOT EXISTS` (Postgres) / `CREATE DATABASE IF NOT EXISTS` (MySQL). A role
46
+ * that cannot create it gets an {@link AuthkitSchemaError} naming the schema and the grant.
47
+ */
48
+ export declare function createDbSchema(client: any, schema: string): Promise<void>;
20
49
  /**
21
50
  * Hidden hook a store built by `lucidAccountStore` exposes so `defineConfig` can tell it
22
51
  * where AuthKit's tables live (the store is built before `defineConfig` sees the `schema`
@@ -1,3 +1,24 @@
1
+ /** Raised for a `schema.name` the database cannot use. The provider fails the boot with it. */
2
+ export class AuthkitSchemaError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'AuthkitSchemaError';
6
+ }
7
+ }
8
+ const SCHEMA_NAME = /^[A-Za-z_][A-Za-z0-9_$]*$/;
9
+ /** Throws {@link AuthkitSchemaError} unless `name` is a plain identifier of at most 63 chars. */
10
+ export function validateSchemaName(name, option = 'schema.name') {
11
+ if (name === undefined)
12
+ return undefined;
13
+ if (typeof name !== 'string' || name.length > 63 || !SCHEMA_NAME.test(name)) {
14
+ throw new AuthkitSchemaError(`authkit: ${option} must match /^[A-Za-z_][A-Za-z0-9_$]*$/ and have at most 63 characters (got ${JSON.stringify(name)})`);
15
+ }
16
+ return name;
17
+ }
18
+ /** `schema.table` when a schema is set (knex quotes each part: `"schema"."table"`), else `table`. */
19
+ export function authkitTable(location, table) {
20
+ return location?.schema ? `${location.schema}.${table}` : table;
21
+ }
1
22
  /**
2
23
  * The query client for AuthKit's tables. `db` is the Lucid manager (`Database`) or a
3
24
  * client the caller already holds (a migration's `this.db`, a transaction), which is
@@ -8,6 +29,49 @@ export function authkitTablesClient(db, location = {}) {
8
29
  return db;
9
30
  return location.connection ? db.connection(location.connection) : db.connection();
10
31
  }
32
+ const SQLITE_DIALECTS = new Set(['sqlite3', 'better-sqlite3', 'libsql']);
33
+ /** Dialects AuthKit writes and creates schemas on. */
34
+ const SCHEMA_DIALECTS = new Set(['postgres', 'redshift', 'mysql']);
35
+ /** The Lucid dialect name of a client or manager (`postgres`, `mysql`, `better-sqlite3`…). */
36
+ export function dialectOf(client) {
37
+ return client?.dialect?.name;
38
+ }
39
+ /**
40
+ * Throws {@link AuthkitSchemaError} when `schema` is set on a dialect AuthKit cannot use it on:
41
+ * SQLite (no schemas) and any dialect other than Postgres and MySQL. AuthKit never ignores the
42
+ * option nor falls back to unqualified tables.
43
+ */
44
+ export function assertSchemaSupported(client, schema) {
45
+ if (!schema)
46
+ return;
47
+ const dialect = dialectOf(client);
48
+ if (dialect && SCHEMA_DIALECTS.has(dialect))
49
+ return;
50
+ throw new AuthkitSchemaError(dialect && SQLITE_DIALECTS.has(dialect)
51
+ ? `authkit: schema.name "${schema}" is set, but the connection uses ${dialect}, which has no schemas. Remove schema.name or use Postgres or MySQL.`
52
+ : `authkit: schema.name "${schema}" is set, but the connection uses ${dialect ?? 'an unknown dialect'}; schema.name supports Postgres and MySQL. Remove schema.name or use one of them.`);
53
+ }
54
+ /**
55
+ * `CREATE SCHEMA IF NOT EXISTS` (Postgres) / `CREATE DATABASE IF NOT EXISTS` (MySQL). A role
56
+ * that cannot create it gets an {@link AuthkitSchemaError} naming the schema and the grant.
57
+ */
58
+ export async function createDbSchema(client, schema) {
59
+ assertSchemaSupported(client, schema);
60
+ const dialect = dialectOf(client);
61
+ const mysql = dialect === 'mysql';
62
+ const sql = mysql
63
+ ? `create database if not exists \`${schema}\``
64
+ : `create schema if not exists "${schema}"`;
65
+ try {
66
+ await client.rawQuery(sql);
67
+ }
68
+ catch (error) {
69
+ const grant = mysql
70
+ ? `GRANT CREATE ON *.* (or create it: CREATE DATABASE \`${schema}\`)`
71
+ : `GRANT CREATE ON DATABASE <db> TO <role> (or create it: CREATE SCHEMA "${schema}")`;
72
+ throw new AuthkitSchemaError(`authkit: could not create schema "${schema}" (${error?.message ?? error}). The database role needs ${grant}.`);
73
+ }
74
+ }
11
75
  /**
12
76
  * Hidden hook a store built by `lucidAccountStore` exposes so `defineConfig` can tell it
13
77
  * where AuthKit's tables live (the store is built before `defineConfig` sees the `schema`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "1.0.0-rc.11",
3
+ "version": "1.0.0-rc.13",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -108,7 +108,7 @@
108
108
  }
109
109
  },
110
110
  "dependencies": {
111
- "@adonis-agora/authkit-core": "1.0.0-rc.1",
111
+ "@adonis-agora/authkit-core": "1.0.0-rc.2",
112
112
  "@simplewebauthn/browser": "14.0.0",
113
113
  "@simplewebauthn/server": "14.1.0",
114
114
  "jose": "6.2.16",
@@ -119,7 +119,7 @@
119
119
  "qrcode": "1.5.4"
120
120
  },
121
121
  "devDependencies": {
122
- "@adonis-agora/authkit-react": "1.0.0-rc.4",
122
+ "@adonis-agora/authkit-react": "1.0.0-rc.5",
123
123
  "@adonis-agora/durable": "0.60.0",
124
124
  "@adonis-agora/telescope": "0.27.1",
125
125
  "@adonisjs/ally": "6.3.2",
@@ -4,7 +4,7 @@ description: >-
4
4
  Set up the @adonis-agora/authkit-server OIDC/OAuth2 Authorization Server (Identity
5
5
  Provider) in an AdonisJS app. Covers `node ace add @adonis-agora/authkit-server`,
6
6
  defineConfig({ issuer, adapter, jwks, ttl, globalRolesClaim, accountStore }) with
7
- adapters.redis({ connection }) vs adapters.database({ connection? }),
7
+ adapters.redis({ connection }) vs adapters.database({ connection?, schema? }),
8
8
  lucidAccountStore(AuthUser), JWKS management ({ source: 'managed' | 'jwks' } or
9
9
  'auto', keystore store file/drive/lucid/redis/hashicorp vaults plus
10
10
  @adonis-agora/authkit-vault-aws/azure/gcp createKeystoreVault add-ons),
@@ -83,12 +83,18 @@ export default authServerConfig
83
83
 
84
84
  `adapter` is where oidc-provider persists grants/tokens/codes: `adapters.redis({
85
85
  connection })` requires `@adonisjs/redis` configured; `adapters.database({
86
- connection? })` uses Lucid and stores artifacts in `authkit_oidc_payloads`, which
87
- `ensureAuthkitSchema` creates on boot (with `schema: { autoManage: false }`, call it
88
- from a migration). The table carries an indexed `account_id` column so per-account
86
+ connection?, schema? })` uses Lucid and stores artifacts in `authkit_oidc_payloads`, which
87
+ `ensureAuthkitSchema` creates on boot (with `schema: { autoManage: false }`, call
88
+ `ensureAuthkitSchema(db)` — or `ensureAuthkitSchema(db, { schema })` when `schema.name`
89
+ is set — from a migration). The `schema` defaults to `schema.name` of `defineConfig`.
90
+ The table carries an indexed `account_id` column so per-account
89
91
  session lookups and revocation stay in SQL; rows with a NULL `account_id` are still
90
- found and get classified on read, so no backfill is needed. Works on Postgres,
91
- MySQL and SQLite; expired rows are pruned every 10 minutes per connection. `accountStore` is the identity contract — `lucidAccountStore(AuthUser)`
92
+ found and get classified on read, so no backfill is needed. Without `schema.name` it
93
+ works on Postgres, MySQL and SQLite; with `schema: { name: 'auth' }` every AuthKit
94
+ table lives in that DB schema (`"auth"."auth_settings"`), the boot creates it
95
+ (`CREATE SCHEMA IF NOT EXISTS`; MySQL: `CREATE DATABASE IF NOT EXISTS`), and SQLite
96
+ fails the boot instead of ignoring the option. Never use `searchPath` for this
97
+ (it leaks across clients behind PgBouncer in transaction mode). Expired rows are pruned every 10 minutes per connection. `accountStore` is the identity contract — `lucidAccountStore(AuthUser)`
92
98
  derives `findAccount`/`verifyCredentials` from your user model.
93
99
 
94
100
  Source: `stubs/config/authkit.stub`, `src/define_config.ts` (`AuthServerConfigInput`),
@@ -112,7 +118,7 @@ blob lives); `{ source: 'jwks', keys }` supplies keys inline.
112
118
 
113
119
  The managed keystore's `store` accepts a path shortcut (`store: 'tmp/keystore.json'`)
114
120
  or `{ driver: ... }`: built-in vaults are `file`, `drive` (a `@adonisjs/drive` disk),
115
- `lucid` (table created on first write), `redis`, and `hashicorp`; for cloud secret
121
+ `lucid` (table created on first write; `schema` overrides, default `schema.name`), `redis`, and `hashicorp`; for cloud secret
116
122
  managers install the add-ons `@adonis-agora/authkit-vault-aws` / `-azure` / `-gcp`
117
123
  and pass their `createKeystoreVault(...)` result as `store` (any object with a
118
124
  `read()` method is accepted). With no `store` at all, keys are ephemeral per boot.