@cosmicdrift/kumiko-server-runtime 0.366.0 → 0.368.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @cosmicdrift/kumiko-server-runtime
2
2
 
3
- Production server boot for Kumiko apps: connections, schema drift gate, seeds, lifecycle and graceful shutdown. The prod counterpart to `runDevApp` in `@cosmicdrift/kumiko-dev-server`, without dev, scaffold or codegen tooling.
3
+ Production server boot for Kumiko apps: connections, schema drift gate, seeds, lifecycle and graceful shutdown. The prod counterpart to `runDevApp` in `@cosmicdrift/kumiko-dev-server`, without dev or scaffold tooling.
4
4
 
5
5
  ## Install
6
6
 
@@ -105,8 +105,14 @@ export async function bootWorkerProcess(options, envSource, profile) {
105
105
  throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
106
106
  }
107
107
  }
108
- const { db, close: closeDb } = createDbConnection(databaseUrl, dbConnectionOptionsFromEnv(envSource));
109
- const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection(databaseUrl, outsideTransactionPoolOptionsFromEnv(envSource));
108
+ const { db, close: closeDb } = createDbConnection({
109
+ url: databaseUrl,
110
+ ...dbConnectionOptionsFromEnv(envSource),
111
+ });
112
+ const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
113
+ url: databaseUrl,
114
+ ...outsideTransactionPoolOptionsFromEnv(envSource),
115
+ });
110
116
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
111
117
  const cacheSync = createRedisCacheSyncBus({
112
118
  redisUrl,
@@ -1,5 +1,6 @@
1
1
  import { type EmailVerificationOptions, type PasswordResetOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
2
  import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
+ import { type UnsubscribeLinkConfig } from "@cosmicdrift/kumiko-bundled-features/delivery";
3
4
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
4
5
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
5
6
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
@@ -30,6 +31,8 @@ export declare function buildBootExtraContext(opts: {
30
31
  readonly deliverQueuedInline?: boolean;
31
32
  /** Receives redacted delivery failures; without it they go to the console. */
32
33
  readonly log?: Logger;
34
+ /** Signs `unsubscribe: true` notifications' mail links; see runProdApp `unsubscribe`. */
35
+ readonly unsubscribeLinks?: UnsubscribeLinkConfig;
33
36
  }): Record<string, unknown>;
34
37
  /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
35
38
  * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
@@ -60,12 +60,13 @@ function buildDeliveryNotifyFactory(opts) {
60
60
  ...(opts.escapeHatchAuditSink && { escapeHatchAuditSink: opts.escapeHatchAuditSink }),
61
61
  ...(opts.log && { log: opts.log }),
62
62
  ...(opts.tenantUserIdsQuery && { tenantUserIdsQuery: opts.tenantUserIdsQuery }),
63
+ ...(opts.unsubscribeLinks && { unsubscribeLinks: opts.unsubscribeLinks }),
63
64
  ...(configAccessorFactory && {
64
65
  tenantConfigFor: (tenantId) => {
65
66
  const systemUser = createSystemUser(tenantId);
66
67
  return configAccessorFactory({
67
68
  user: { id: systemUser.id, tenantId },
68
- db: createTenantDb(opts.db, tenantId),
69
+ db: createTenantDb({ db: opts.db, tenantId }),
69
70
  ...(opts.secrets && { secrets: opts.secrets }),
70
71
  });
71
72
  },
@@ -147,6 +148,7 @@ export function buildBootExtraContext(opts) {
147
148
  ...(hasTenantFeature && { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
148
149
  configResolver: bootConfigResolver,
149
150
  deliverQueuedInline: opts.deliverQueuedInline,
151
+ unsubscribeLinks: opts.unsubscribeLinks,
150
152
  }),
151
153
  }),
152
154
  // Top-level provider so feature jobs (secrets rotate, config reencrypt)
@@ -1,6 +1,7 @@
1
1
  /// <reference types="bun-types" preserve="true" />
2
2
  import { type AccountLockoutOptions, type AccountUnlockOptions, type AuthMailLocale, type AuthPaths, type EmailVerificationOptions, type InviteOptions, type PasswordResetOptions, type SignupOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
3
3
  import { type SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
4
+ import { type UnsubscribeLinkConfig } from "@cosmicdrift/kumiko-bundled-features/delivery";
4
5
  import { type ExtraRouteDefinition, type KumikoServeEnv, type PostAuthLandingResolver, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
5
6
  import { type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
6
7
  import { type EffectiveFeaturesResolver, type FeatureDefinition, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
@@ -14,6 +15,7 @@ import type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery } from "@cosmi
14
15
  import { Redis } from "ioredis";
15
16
  import { type SystemWireDeps } from "./extra-routes-deps.js";
16
17
  import { type SecurityHeadersOption } from "./security-headers.js";
18
+ import { type SeedCreateFileRef } from "./seed-dispatcher.js";
17
19
  export { buildBunServeOptions } from "./bun-serve-options.js";
18
20
  export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
19
21
  export { staticCachePolicy } from "./run-prod-app-static-files.js";
@@ -175,6 +177,9 @@ export type ProdSeedFn = (deps: {
175
177
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
176
178
  registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
177
179
  dispatcher: import("@cosmicdrift/kumiko-framework/pipeline").Dispatcher;
180
+ /** Stores bytes as a fileRef of `tenantId` (same validation and storage
181
+ * layout as POST /files) without a helper write handler. */
182
+ createFileRef: SeedCreateFileRef;
178
183
  }) => Promise<void>;
179
184
  /** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
180
185
  * Argument bekommen. Closure dann in der returned Config (z.B. ein
@@ -392,6 +397,22 @@ export type RunProdAppOptions = {
392
397
  * inside buildServer, right after the r.httpRoute loop, before seeds
393
398
  * (kumiko-framework#3050). */
394
399
  readonly extraRoutes?: readonly ExtraRouteDefinition[];
400
+ /** Framework-managed unsubscribe links. Mounts `createUnsubscribeRoutes({ secret })` and makes
401
+ * delivery sign `data.unsubscribeUrl` (path `DELIVERY_UNSUBSCRIBE_PATH`) for every email
402
+ * notification declared with `unsubscribe: true`, for user and `route` recipients; a link the
403
+ * caller put into `data.unsubscribeUrl` wins. `secret` must be dedicated (≥32 chars), not the
404
+ * session JWT secret. Links point at the origin of `auth.publicOrigin`, then `auth.mail.baseUrl`,
405
+ * then the `APP_BASE_URL` env; with none of them no link is added (boot warning). Address
406
+ * recipients need a blind-index key, otherwise they get no link. Do not also mount
407
+ * `createUnsubscribeRoutes` via `extraRoutes`. `tenantOrigin` points a tenant's links at
408
+ * its own subdomain or custom domain (the host must serve this app's `/api` routes); null,
409
+ * a throw or a non-https value fall back to the app origin. The link carries the mail's
410
+ * locale, so the confirmation pages speak the mail's language. An app reacts to an address
411
+ * opt-out with an `r.job` triggered on `DeliveryHandlers.unsubscribeAddress`. */
412
+ readonly unsubscribe?: {
413
+ readonly secret: string;
414
+ readonly tenantOrigin?: UnsubscribeLinkConfig["tenantOrigin"];
415
+ };
395
416
  /** Hook for app-wired co-running components that need the system-write
396
417
  * dispatcher (e.g. wiring a webhook plugin's system-scoped secrets, or
397
418
  * starting a co-running supervisor) — runs after buildServer, before
@@ -43,12 +43,13 @@ import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bund
43
43
  import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
44
44
  import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
45
45
  import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
46
+ import { createUnsubscribeRoutes, } from "@cosmicdrift/kumiko-bundled-features/delivery";
46
47
  import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
47
48
  import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-features/secrets";
48
49
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
49
50
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
50
51
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
51
- import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
52
+ import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, publicOriginOf, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
52
53
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
53
54
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
54
55
  import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
@@ -57,6 +58,7 @@ import { KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@co
57
58
  import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
58
59
  import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
59
60
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
61
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
60
62
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
61
63
  import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
62
64
  import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -73,10 +75,11 @@ import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-se
73
75
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
74
76
  import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
75
77
  import { assertPiiBootInvariants } from "./pii-boot-gate.js";
76
- import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
78
+ import { APP_BASE_URL_ENV, resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
77
79
  import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
78
80
  import { buildStaticFallback } from "./run-prod-app-static-files.js";
79
81
  import { withSecurityHeaders } from "./security-headers.js";
82
+ import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
80
83
  import { assertSessionBootInvariants } from "./session-boot-gate.js";
81
84
  import { shouldWireProdSessions } from "./session-wiring.js";
82
85
  import { stripNoRouteMatchHeader } from "./try-hono-first.js";
@@ -288,6 +291,27 @@ export async function runProdApp(options) {
288
291
  console.warn(message);
289
292
  })
290
293
  : undefined;
294
+ const unsubscribeBaseUrl = options.unsubscribe
295
+ ? publicOriginOf(effectiveAuth?.publicOrigin ??
296
+ effectiveAuth?.mail?.baseUrl ??
297
+ envSource[APP_BASE_URL_ENV] ??
298
+ "")
299
+ : undefined;
300
+ if (options.unsubscribe && unsubscribeBaseUrl === undefined) {
301
+ createFallbackLogger("runProdApp").warn(`unsubscribe is enabled but no valid public app URL is known (auth.publicOrigin, auth.mail.baseUrl or ${APP_BASE_URL_ENV}), so mails get no unsubscribe link.`);
302
+ }
303
+ const unsubscribeLinks = options.unsubscribe && unsubscribeBaseUrl !== undefined
304
+ ? {
305
+ secret: options.unsubscribe.secret,
306
+ baseUrl: unsubscribeBaseUrl,
307
+ ...(options.unsubscribe.tenantOrigin !== undefined && {
308
+ tenantOrigin: options.unsubscribe.tenantOrigin,
309
+ }),
310
+ }
311
+ : undefined;
312
+ const unsubscribeRoutes = options.unsubscribe
313
+ ? createUnsubscribeRoutes({ secret: options.unsubscribe.secret })
314
+ : [];
291
315
  // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
292
316
  // password via composeFeatures — same source-of-truth as runDevApp
293
317
  // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
@@ -346,8 +370,14 @@ export async function runProdApp(options) {
346
370
  throw new Error(`[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
347
371
  }
348
372
  }
349
- const { db, close: closeDb } = createDbConnection(databaseUrl, dbConnectionOptionsFromEnv(envSource));
350
- const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection(databaseUrl, outsideTransactionPoolOptionsFromEnv(envSource));
373
+ const { db, close: closeDb } = createDbConnection({
374
+ url: databaseUrl,
375
+ ...dbConnectionOptionsFromEnv(envSource),
376
+ });
377
+ const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
378
+ url: databaseUrl,
379
+ ...outsideTransactionPoolOptionsFromEnv(envSource),
380
+ });
351
381
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
352
382
  await waitForDatabase(db, { processName: "runProdApp" });
353
383
  // Sprint-8a Tier-Composition auto-wire: scan features for a
@@ -452,6 +482,7 @@ export async function runProdApp(options) {
452
482
  crypto: bootCrypto,
453
483
  ...(options.kms && { kms: options.kms }),
454
484
  ...(bootLogger && { log: bootLogger }),
485
+ ...(unsubscribeLinks && { unsubscribeLinks }),
455
486
  });
456
487
  const extraContext = {
457
488
  ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
@@ -534,7 +565,9 @@ export async function runProdApp(options) {
534
565
  ...(options.rateLimit && { rateLimit: options.rateLimit }),
535
566
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
536
567
  ...(options.errorDocs && { errorDocs: options.errorDocs }),
537
- ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
568
+ ...((options.extraRoutes || options.unsubscribe) && {
569
+ extraRoutes: [...(options.extraRoutes ?? []), ...unsubscribeRoutes],
570
+ }),
538
571
  ...(effectiveAuth && {
539
572
  auth: {
540
573
  membershipQuery: TenantQueries.memberships,
@@ -703,12 +736,20 @@ export async function runProdApp(options) {
703
736
  db,
704
737
  ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
705
738
  });
706
- const seedDispatcher = (options.seeds?.length ?? 0) > 0 || options.seedsDir !== undefined
707
- ? createDispatcher(registry, withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync))
739
+ const hasSeeds = (options.seeds?.length ?? 0) > 0 || options.seedsDir !== undefined;
740
+ const seedContext = hasSeeds
741
+ ? withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync)
742
+ : undefined;
743
+ const seedDispatcher = seedContext
744
+ ? withSeedErrorContext(createDispatcher(registry, seedContext, {
745
+ ...baseEntrypointOptions.dispatcherOptions,
746
+ ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
747
+ }))
708
748
  : undefined;
709
- if (seedDispatcher) {
749
+ if (seedDispatcher && seedContext) {
750
+ const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
710
751
  for (const seed of options.seeds ?? []) {
711
- await seed({ db, registry, dispatcher: seedDispatcher });
752
+ await seed({ db, registry, dispatcher: seedDispatcher, createFileRef });
712
753
  }
713
754
  }
714
755
  // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
@@ -0,0 +1,11 @@
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type AppContext, type Registry, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type CreatedFileRef, type CreateFileRefInput } from "@cosmicdrift/kumiko-framework/files";
4
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
5
+ export type SeedCreateFileRef = (tenantId: TenantId, input: CreateFileRefInput) => Promise<WriteResult<CreatedFileRef>>;
6
+ export declare function makeSeedCreateFileRef(deps: {
7
+ readonly db: DbConnection;
8
+ readonly registry: Registry;
9
+ readonly context: AppContext;
10
+ }): SeedCreateFileRef;
11
+ export declare function withSeedErrorContext<D extends Pick<Dispatcher, "write">>(dispatcher: D): D;
@@ -0,0 +1,36 @@
1
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { createFileContext, createFileRefFromBytes, } from "@cosmicdrift/kumiko-framework/files";
5
+ export function makeSeedCreateFileRef(deps) {
6
+ const { db, registry, context } = deps;
7
+ const fileResolver = context._fileProviderResolver;
8
+ return (tenantId, input) => createFileRefFromBytes({
9
+ db: createTenantDb({ db, tenantId }),
10
+ files: fileResolver
11
+ ? createFileContext(() => fileResolver(tenantId), tenantId)
12
+ : context.files,
13
+ registry,
14
+ user: createSystemUser(tenantId),
15
+ }, input);
16
+ }
17
+ // The dispatcher masks a thrown handler error as "internal error" and keeps the
18
+ // cause only on a side channel; a seed has no HTTP client to protect, so the
19
+ // handler name and cause message go into the message the seed caller sees.
20
+ export function withSeedErrorContext(dispatcher) {
21
+ return {
22
+ ...dispatcher,
23
+ async write(type, payload, user, requestId) {
24
+ const result = await dispatcher.write(type, payload, user, requestId);
25
+ if (result.isSuccess || result.error.code !== "internal_error")
26
+ return result;
27
+ const cause = reraiseAsKumikoError(result.error).cause;
28
+ const causeMessage = cause instanceof Error ? cause.message : undefined;
29
+ const detail = causeMessage ? `${type}: ${causeMessage}` : type;
30
+ return {
31
+ isSuccess: false,
32
+ error: { ...result.error, message: `${result.error.message} (${detail})` },
33
+ };
34
+ },
35
+ };
36
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.366.0",
4
- "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
3
+ "version": "0.368.0",
4
+ "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
7
  "repository": {
@@ -84,16 +84,19 @@
84
84
  }
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-bundled-features": "0.366.0",
88
- "@cosmicdrift/kumiko-framework": "0.366.0",
89
- "@cosmicdrift/kumiko-headless": "0.366.0",
90
- "@cosmicdrift/kumiko-renderer-mail-html": "0.366.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.366.0",
92
- "@cosmicdrift/kumiko-types": "0.366.0",
87
+ "@cosmicdrift/kumiko-bundled-features": "0.368.0",
88
+ "@cosmicdrift/kumiko-framework": "0.368.0",
89
+ "@cosmicdrift/kumiko-headless": "0.368.0",
90
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.368.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.368.0",
92
+ "@cosmicdrift/kumiko-types": "0.368.0",
93
93
  "bun-types": "^1.3.13",
94
94
  "ioredis": "^5.10.1",
95
95
  "temporal-polyfill": "^0.3.2"
96
96
  },
97
+ "devDependencies": {
98
+ "@cosmicdrift/kumiko-testing": "0.368.0"
99
+ },
97
100
  "publishConfig": {
98
101
  "registry": "https://registry.npmjs.org",
99
102
  "access": "public",