@freema/drobek-modules 0.3.3 → 0.5.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.
@@ -78,6 +78,7 @@ var CORE_ERROR_CODES = [
78
78
  "user_confirmation_required",
79
79
  "gallery_hidden",
80
80
  "gallery_disabled",
81
+ "not_duplicable",
81
82
  "publish_not_approved",
82
83
  "publish_blocked",
83
84
  // app assets (MCP asset tools, the upload URL)
@@ -95,6 +96,8 @@ var CORE_ERROR_CODES = [
95
96
  "domain_taken",
96
97
  "domain_not_verified",
97
98
  "dns_unavailable",
99
+ // proxy upstreams (MCP upstream tools)
100
+ "upstream_already_registered",
98
101
  // compile.errors[]
99
102
  "build_error",
100
103
  "unresolved_import",
@@ -221,6 +224,8 @@ __export(schema_exports, {
221
224
  blobs: () => blobs,
222
225
  compileStatusEnum: () => compileStatusEnum,
223
226
  domains: () => domains,
227
+ galleryLikes: () => galleryLikes,
228
+ galleryOpens: () => galleryOpens,
224
229
  membershipRoleEnum: () => membershipRoleEnum,
225
230
  memberships: () => memberships,
226
231
  moduleConfigs: () => moduleConfigs,
@@ -340,9 +345,16 @@ var apps = pgTable("apps", {
340
345
  /** NSO-340: the gallery's one-line public description (plain text, ≤ 160 chars). */
341
346
  galleryDescription: text("gallery_description"),
342
347
  /** NSO-340: a super-admin hid the gallery entry (non-null = hidden, whatever the owner sets). */
343
- galleryHiddenAt: timestamp("gallery_hidden_at")
348
+ galleryHiddenAt: timestamp("gallery_hidden_at"),
349
+ /** NSO-340: the owner lets signed-in people duplicate the listed app into their own workspace. */
350
+ galleryAllowDuplicate: boolean("gallery_allow_duplicate").notNull().default(false),
351
+ /** NSO-340: the gallery app this one was duplicated from (null when the source is gone or none). */
352
+ duplicatedFromAppId: text("duplicated_from_app_id").references(() => apps.id, { onDelete: "set null" }),
353
+ /** The source's slug at duplication time, kept when the source is deleted. */
354
+ duplicatedFromSlug: text("duplicated_from_slug")
344
355
  }, (t) => [
345
356
  uniqueIndex("apps_slug_uq").on(t.slug),
357
+ index("apps_duplicated_from_idx").on(t.duplicatedFromAppId).where(sql`${t.duplicatedFromAppId} IS NOT NULL`),
346
358
  // The public gallery page reads listed apps newest-published first (NSO-340).
347
359
  index("apps_gallery_idx").on(t.publishedAt.desc(), t.slug.desc()).where(sql`${t.galleryListed}`),
348
360
  index("apps_workspace_idx").on(t.workspaceId),
@@ -352,6 +364,16 @@ var apps = pgTable("apps", {
352
364
  // words). A deleted app may carry its released-slug tombstone instead (0016).
353
365
  check("apps_slug_format", sql`(${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND char_length(${t.slug}) BETWEEN 3 AND 40) OR (${t.deletedAt} IS NOT NULL AND ${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*~deleted-[a-z0-9]+$')`)
354
366
  ]);
367
+ var galleryLikes = pgTable("gallery_likes", {
368
+ appId: text("app_id").notNull().references(() => apps.id, { onDelete: "cascade" }),
369
+ userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
370
+ createdAt: timestamp("created_at").notNull().defaultNow()
371
+ }, (t) => [primaryKey({ columns: [t.appId, t.userId] })]);
372
+ var galleryOpens = pgTable("gallery_opens", {
373
+ appId: text("app_id").notNull().references(() => apps.id, { onDelete: "cascade" }),
374
+ day: text("day").notNull(),
375
+ count: integer("count").notNull().default(0)
376
+ }, (t) => [primaryKey({ columns: [t.appId, t.day] })]);
355
377
  var blobs = pgTable("blobs", {
356
378
  sha256: text("sha256").primaryKey(),
357
379
  bytes: bytea("bytes").notNull(),
@@ -695,16 +717,16 @@ async function runJournalMigrations(opts) {
695
717
  const databaseUrl = opts.databaseUrl ?? process.env.DATABASE_URL;
696
718
  if (!databaseUrl)
697
719
  throw new Error("DATABASE_URL is required");
698
- const sql15 = postgres2(databaseUrl, { max: 1, connect_timeout: 10, onnotice: () => {
720
+ const sql17 = postgres2(databaseUrl, { max: 1, connect_timeout: 10, onnotice: () => {
699
721
  } });
700
722
  try {
701
- await migrate(drizzle2(sql15), {
723
+ await migrate(drizzle2(sql17), {
702
724
  migrationsFolder: opts.migrationsFolder,
703
725
  migrationsTable: opts.migrationsTable,
704
726
  migrationsSchema: "drizzle"
705
727
  });
706
728
  } finally {
707
- await sql15.end({ timeout: 5 });
729
+ await sql17.end({ timeout: 5 });
708
730
  }
709
731
  }
710
732
 
@@ -921,12 +943,12 @@ function looksLikeSvg(head) {
921
943
  }
922
944
  if (!doctype && matchesAt(DOCTYPE_SVG_RE, head, i)) {
923
945
  doctype = true;
924
- const gt = head.indexOf(">", i);
946
+ const gt2 = head.indexOf(">", i);
925
947
  const bracket = head.indexOf("[", i);
926
- if (gt < 0)
948
+ if (gt2 < 0)
927
949
  return false;
928
- if (bracket < 0 || gt < bracket) {
929
- i = gt + 1;
950
+ if (bracket < 0 || gt2 < bracket) {
951
+ i = gt2 + 1;
930
952
  continue;
931
953
  }
932
954
  const close = head.indexOf("]", bracket + 1);
@@ -952,6 +974,20 @@ function hasControlBytes(chunk) {
952
974
  return false;
953
975
  }
954
976
 
977
+ // packages/core/dist/cookies.js
978
+ function readCookieValue(header, name) {
979
+ if (!header)
980
+ return null;
981
+ for (const part of header.split(";")) {
982
+ const eq22 = part.indexOf("=");
983
+ if (eq22 === -1)
984
+ continue;
985
+ if (part.slice(0, eq22).trim() === name)
986
+ return part.slice(eq22 + 1).trim();
987
+ }
988
+ return null;
989
+ }
990
+
955
991
  // packages/core/dist/redis.js
956
992
  import { Redis } from "ioredis";
957
993
  var client = null;
@@ -1182,6 +1218,10 @@ var AUDIT_ACTIONS = {
1182
1218
  appGalleryHidden: "app.gallery_hidden",
1183
1219
  /** NSO-340: a super-admin showed a hidden gallery entry again. */
1184
1220
  appGalleryUnhidden: "app.gallery_unhidden",
1221
+ /** NSO-340: this app was created as a copy of a gallery app (meta: from = the source slug, version). */
1222
+ appDuplicate: "app.duplicate",
1223
+ /** NSO-340: someone duplicated this gallery app into their own workspace (no details about them). */
1224
+ appDuplicated: "app.duplicated",
1185
1225
  /** NSO-346: a super-admin enabled an opt-in platform module for the workspace (meta: module). */
1186
1226
  moduleWorkspaceEnable: "module.workspace_enable",
1187
1227
  /** NSO-346: a super-admin disabled an opt-in platform module for the workspace (meta: module). */
@@ -1228,7 +1268,7 @@ async function writeAudit(entry, executor) {
1228
1268
  }
1229
1269
 
1230
1270
  // packages/audit/dist/read.server.js
1231
- import { and, desc, eq, lt, or } from "drizzle-orm";
1271
+ import { and, desc, eq, gte, lt, or } from "drizzle-orm";
1232
1272
 
1233
1273
  // packages/audit/dist/retention.server.js
1234
1274
  import { lt as lt2 } from "drizzle-orm";
@@ -1253,7 +1293,7 @@ var DEFAULT_APPS_MAX_PER_WORKSPACE = 50;
1253
1293
 
1254
1294
  // packages/apps/dist/versions.server.js
1255
1295
  import { createHash as createHash3 } from "node:crypto";
1256
- import { and as and8, desc as desc5, eq as eq8, gte, inArray as inArray4, sql as sql5 } from "drizzle-orm";
1296
+ import { and as and8, desc as desc5, eq as eq8, gte as gte2, inArray as inArray4, sql as sql5 } from "drizzle-orm";
1257
1297
 
1258
1298
  // packages/compile/dist/compiler.js
1259
1299
  import { mkdtempSync } from "node:fs";
@@ -1925,7 +1965,8 @@ var reportedAppColumns = {
1925
1965
  name: apps.name,
1926
1966
  workspaceId: apps.workspaceId,
1927
1967
  workspaceSlug: workspaces.slug,
1928
- lockedReason: apps.lockedReason
1968
+ lockedReason: apps.lockedReason,
1969
+ published: sql2`(${apps.publishedVersionId} IS NOT NULL)`.mapWith(Boolean)
1929
1970
  };
1930
1971
  var SCAN_MAX_FILE = 1024 * 1024;
1931
1972
  var SCAN_MAX_TOTAL = 4 * 1024 * 1024;
@@ -2373,7 +2414,27 @@ var CONTROL_RE = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+", "
2373
2414
 
2374
2415
  // packages/apps/dist/gallery.server.js
2375
2416
  import { and as and12, asc as asc3, desc as desc6, eq as eq12, isNotNull as isNotNull7, isNull as isNull5, sql as sql8 } from "drizzle-orm";
2376
- var itemColumns = { slug: apps.slug, name: apps.name, description: apps.galleryDescription, publishedAt: apps.publishedAt };
2417
+ var baseColumns = {
2418
+ slug: apps.slug,
2419
+ name: apps.name,
2420
+ description: apps.galleryDescription,
2421
+ publishedAt: apps.publishedAt,
2422
+ // Names only, from the current saved configuration. A correlated aggregate
2423
+ // keeps pagination/counts intact and avoids a query per card. No extension
2424
+ // or module runtime dependency is needed by this public core API.
2425
+ modules: sql8`(select coalesce(jsonb_agg(${moduleConfigs.module} order by ${moduleConfigs.module}), '[]'::jsonb)
2426
+ from ${moduleConfigs} where ${moduleConfigs.appId} = ${apps.id}
2427
+ and jsonb_typeof(${moduleConfigs.config}) = 'object' and ${moduleConfigs.config} <> '{}'::jsonb)`,
2428
+ duplicable: apps.galleryAllowDuplicate,
2429
+ // Live copies; the alias keeps the outer "apps" row in reach of the subquery.
2430
+ duplicates: sql8`(select count(*)::int from "apps" as "copies" where "copies"."duplicated_from_app_id" = "apps"."id" and "copies"."deleted_at" is null)`
2431
+ };
2432
+
2433
+ // packages/apps/dist/duplicate.server.js
2434
+ import { and as and13, count as count2, eq as eq13, gt, isNull as isNull6, sql as sql9 } from "drizzle-orm";
2435
+
2436
+ // packages/apps/dist/gallery-engagement.server.js
2437
+ import { and as and14, eq as eq14, gte as gte3, sql as sql10 } from "drizzle-orm";
2377
2438
 
2378
2439
  // packages/modules/dist/limits.js
2379
2440
  var LIMITS_CACHE_TTL_SEC = 60;
@@ -2570,20 +2631,8 @@ function endUserEpochKey(appId) {
2570
2631
  }
2571
2632
  var SESSION_PROVIDER_RE = /^[a-z][a-z0-9]{1,15}$/;
2572
2633
  var SESSION_CONNECTION_RE = /^[A-Za-z0-9_-]{16,64}$/;
2573
- function readCookie(header, name) {
2574
- if (!header)
2575
- return null;
2576
- for (const part of header.split(";")) {
2577
- const eq20 = part.indexOf("=");
2578
- if (eq20 === -1)
2579
- continue;
2580
- if (part.slice(0, eq20).trim() === name)
2581
- return part.slice(eq20 + 1).trim();
2582
- }
2583
- return null;
2584
- }
2585
2634
  function readEndUserToken(cookieHeader, secure) {
2586
- const token = readCookie(cookieHeader, endUserCookieName(secure));
2635
+ const token = readCookieValue(cookieHeader, endUserCookieName(secure));
2587
2636
  return token && END_USER_TOKEN_RE.test(token) ? token : null;
2588
2637
  }
2589
2638
  function parseEndUserSession(raw) {
@@ -2684,7 +2733,7 @@ function cookiePrincipalResolver(opts) {
2684
2733
  }
2685
2734
 
2686
2735
  // packages/modules/dist/secrets.server.js
2687
- import { and as and14, eq as eq14, inArray as inArray7 } from "drizzle-orm";
2736
+ import { and as and16, eq as eq16, inArray as inArray7 } from "drizzle-orm";
2688
2737
 
2689
2738
  // packages/proxy/dist/errors.js
2690
2739
  var ProxyError = class extends Error {
@@ -2810,7 +2859,7 @@ import https from "node:https";
2810
2859
  var DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
2811
2860
 
2812
2861
  // packages/proxy/dist/upstreams.server.js
2813
- import { and as and13, eq as eq13, inArray as inArray6, sql as sql9 } from "drizzle-orm";
2862
+ import { and as and15, eq as eq15, inArray as inArray6, sql as sql11 } from "drizzle-orm";
2814
2863
 
2815
2864
  // packages/proxy/dist/forward.server.js
2816
2865
  import { promisify } from "node:util";
@@ -2884,11 +2933,11 @@ async function setModuleSecret(input) {
2884
2933
  });
2885
2934
  }
2886
2935
  async function deleteModuleSecret(appId, module, name) {
2887
- const rows = await getDb().delete(moduleSecrets).where(and14(eq14(moduleSecrets.appId, appId), eq14(moduleSecrets.module, module), eq14(moduleSecrets.name, name))).returning({ name: moduleSecrets.name });
2936
+ const rows = await getDb().delete(moduleSecrets).where(and16(eq16(moduleSecrets.appId, appId), eq16(moduleSecrets.module, module), eq16(moduleSecrets.name, name))).returning({ name: moduleSecrets.name });
2888
2937
  return rows.length > 0;
2889
2938
  }
2890
2939
  async function getModuleSecret(appId, module, name, env) {
2891
- const [row] = await getDb().select().from(moduleSecrets).where(and14(eq14(moduleSecrets.appId, appId), eq14(moduleSecrets.module, module), eq14(moduleSecrets.name, name))).limit(1);
2940
+ const [row] = await getDb().select().from(moduleSecrets).where(and16(eq16(moduleSecrets.appId, appId), eq16(moduleSecrets.module, module), eq16(moduleSecrets.name, name))).limit(1);
2892
2941
  if (!row)
2893
2942
  return null;
2894
2943
  return decryptSecret({ ciphertext: row.ciphertext, iv: row.iv, authTag: row.authTag, wrappedDek: row.wrappedDek, kekId: row.kekId }, env);
@@ -2896,18 +2945,18 @@ async function getModuleSecret(appId, module, name, env) {
2896
2945
  async function secretsStatus(appId, module, names) {
2897
2946
  if (names.length === 0)
2898
2947
  return /* @__PURE__ */ new Map();
2899
- const rows = await getDb().select({ name: moduleSecrets.name, updatedAt: moduleSecrets.updatedAt }).from(moduleSecrets).where(and14(eq14(moduleSecrets.appId, appId), eq14(moduleSecrets.module, module), inArray7(moduleSecrets.name, names)));
2948
+ const rows = await getDb().select({ name: moduleSecrets.name, updatedAt: moduleSecrets.updatedAt }).from(moduleSecrets).where(and16(eq16(moduleSecrets.appId, appId), eq16(moduleSecrets.module, module), inArray7(moduleSecrets.name, names)));
2900
2949
  return new Map(rows.map((r) => [r.name, r.updatedAt]));
2901
2950
  }
2902
2951
  async function secretsSet(appId, module, names) {
2903
2952
  if (names.length === 0)
2904
2953
  return /* @__PURE__ */ new Set();
2905
- const rows = await getDb().select({ name: moduleSecrets.name }).from(moduleSecrets).where(and14(eq14(moduleSecrets.appId, appId), eq14(moduleSecrets.module, module), inArray7(moduleSecrets.name, names)));
2954
+ const rows = await getDb().select({ name: moduleSecrets.name }).from(moduleSecrets).where(and16(eq16(moduleSecrets.appId, appId), eq16(moduleSecrets.module, module), inArray7(moduleSecrets.name, names)));
2906
2955
  return new Set(rows.map((r) => r.name));
2907
2956
  }
2908
2957
 
2909
2958
  // packages/modules/dist/configs.server.js
2910
- import { and as and15, eq as eq15, inArray as inArray8 } from "drizzle-orm";
2959
+ import { and as and17, eq as eq17, inArray as inArray8 } from "drizzle-orm";
2911
2960
  function asObject2(v) {
2912
2961
  return v && typeof v === "object" && !Array.isArray(v) ? v : {};
2913
2962
  }
@@ -2924,7 +2973,7 @@ function asPending(v) {
2924
2973
  };
2925
2974
  }
2926
2975
  async function readConfigRow(appId, module, executor = getDb()) {
2927
- const [row] = await executor.select().from(moduleConfigs).where(and15(eq15(moduleConfigs.appId, appId), eq15(moduleConfigs.module, module))).limit(1);
2976
+ const [row] = await executor.select().from(moduleConfigs).where(and17(eq17(moduleConfigs.appId, appId), eq17(moduleConfigs.module, module))).limit(1);
2928
2977
  if (!row)
2929
2978
  return { config: {}, pending: null, updatedAt: null };
2930
2979
  return { config: asObject2(row.config), pending: asPending(row.pending), updatedAt: row.updatedAt };
@@ -2933,7 +2982,7 @@ async function readConfigRows(appId, modules) {
2933
2982
  const out = /* @__PURE__ */ new Map();
2934
2983
  if (modules.length === 0)
2935
2984
  return out;
2936
- const rows = await getDb().select().from(moduleConfigs).where(and15(eq15(moduleConfigs.appId, appId), inArray8(moduleConfigs.module, modules)));
2985
+ const rows = await getDb().select().from(moduleConfigs).where(and17(eq17(moduleConfigs.appId, appId), inArray8(moduleConfigs.module, modules)));
2937
2986
  for (const r of rows) {
2938
2987
  out.set(r.module, { config: asObject2(r.config), pending: asPending(r.pending), updatedAt: r.updatedAt });
2939
2988
  }
@@ -2942,7 +2991,7 @@ async function readConfigRows(appId, modules) {
2942
2991
  async function withLockedConfig(appId, module, fn) {
2943
2992
  return getDb().transaction(async (tx) => {
2944
2993
  await tx.insert(moduleConfigs).values({ appId, module }).onConflictDoNothing();
2945
- const [row] = await tx.select().from(moduleConfigs).where(and15(eq15(moduleConfigs.appId, appId), eq15(moduleConfigs.module, module))).for("update");
2994
+ const [row] = await tx.select().from(moduleConfigs).where(and17(eq17(moduleConfigs.appId, appId), eq17(moduleConfigs.module, module))).for("update");
2946
2995
  const current = {
2947
2996
  config: asObject2(row?.config),
2948
2997
  pending: asPending(row?.pending),
@@ -2954,7 +3003,7 @@ async function withLockedConfig(appId, module, fn) {
2954
3003
  set.config = next.config;
2955
3004
  if (next.pending !== void 0)
2956
3005
  set.pending = next.pending;
2957
- await tx.update(moduleConfigs).set(set).where(and15(eq15(moduleConfigs.appId, appId), eq15(moduleConfigs.module, module)));
3006
+ await tx.update(moduleConfigs).set(set).where(and17(eq17(moduleConfigs.appId, appId), eq17(moduleConfigs.module, module)));
2958
3007
  };
2959
3008
  return fn(current, write, tx);
2960
3009
  });
@@ -3415,7 +3464,7 @@ function redisMailGuard(opts) {
3415
3464
  const ttl = await r.pttl(key);
3416
3465
  return ttl > 0 ? ttl / 1e3 : fallbackSec;
3417
3466
  };
3418
- const count2 = async (r, key, n) => {
3467
+ const count3 = async (r, key, n) => {
3419
3468
  const c = await r.incrby(key, n);
3420
3469
  if (c === n || await r.pttl(key) < 0)
3421
3470
  await r.pexpire(key, HOUR_MS2);
@@ -3478,7 +3527,7 @@ function redisMailGuard(opts) {
3478
3527
  if (!refusal) {
3479
3528
  const key = mailAppCounterKey(meta.app_id, cls);
3480
3529
  const share = appShareOf(budgets, cls);
3481
- const used = await count2(r, key, recipients);
3530
+ const used = await count3(r, key, recipients);
3482
3531
  if (used > share) {
3483
3532
  if (used - recipients <= share) {
3484
3533
  log.warn(`module e-mail: an app used its hourly share of ${cls === "sign_in" ? "sign-in codes" : "notifications"}`, {
@@ -3494,7 +3543,7 @@ function redisMailGuard(opts) {
3494
3543
  if (!refusal) {
3495
3544
  const key = mailWorkspaceCounterKey(meta.workspace_id, cls);
3496
3545
  const share = workspaceShareOf(budgets, cls);
3497
- const used = await count2(r, key, recipients);
3546
+ const used = await count3(r, key, recipients);
3498
3547
  if (used > share) {
3499
3548
  if (used - recipients <= share) {
3500
3549
  log.warn(`module e-mail: a workspace used its hourly share of ${cls === "sign_in" ? "sign-in codes" : "notifications"}`, {
@@ -3508,7 +3557,7 @@ function redisMailGuard(opts) {
3508
3557
  }
3509
3558
  }
3510
3559
  if (!refusal) {
3511
- const used = await count2(r, MAIL_COUNTER_KEYS[cls], recipients);
3560
+ const used = await count3(r, MAIL_COUNTER_KEYS[cls], recipients);
3512
3561
  if (used > budgets[cls]) {
3513
3562
  await r.set(MAIL_PAUSE_KEYS[cls], "1", "PX", pauseMs);
3514
3563
  await r.set(MAIL_COUNTER_KEYS[cls], "0", "PX", pauseMs);
@@ -4117,43 +4166,43 @@ function readModulesLock(modulesDir) {
4117
4166
  // packages/modules/dist/migration-lint.js
4118
4167
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "node:fs";
4119
4168
  import { join as join7 } from "node:path";
4120
- function blank(sql15) {
4121
- const out = sql15.split("");
4169
+ function blank(sql17) {
4170
+ const out = sql17.split("");
4122
4171
  const wipe = (from, to) => {
4123
4172
  for (let k = from; k < to; k++)
4124
4173
  if (out[k] !== "\n")
4125
4174
  out[k] = " ";
4126
4175
  };
4127
4176
  let i = 0;
4128
- while (i < sql15.length) {
4129
- const c = sql15[i];
4130
- if (c === "-" && sql15[i + 1] === "-") {
4131
- const end = sql15.indexOf("\n", i);
4132
- const stop = end === -1 ? sql15.length : end;
4177
+ while (i < sql17.length) {
4178
+ const c = sql17[i];
4179
+ if (c === "-" && sql17[i + 1] === "-") {
4180
+ const end = sql17.indexOf("\n", i);
4181
+ const stop = end === -1 ? sql17.length : end;
4133
4182
  wipe(i, stop);
4134
4183
  i = stop;
4135
- } else if (c === "/" && sql15[i + 1] === "*") {
4136
- const end = sql15.indexOf("*/", i + 2);
4137
- const stop = end === -1 ? sql15.length : end + 2;
4184
+ } else if (c === "/" && sql17[i + 1] === "*") {
4185
+ const end = sql17.indexOf("*/", i + 2);
4186
+ const stop = end === -1 ? sql17.length : end + 2;
4138
4187
  wipe(i, stop);
4139
4188
  i = stop;
4140
4189
  } else if (c === "'") {
4141
4190
  let j = i + 1;
4142
- while (j < sql15.length) {
4143
- if (sql15[j] === "'" && sql15[j + 1] === "'")
4191
+ while (j < sql17.length) {
4192
+ if (sql17[j] === "'" && sql17[j + 1] === "'")
4144
4193
  j += 2;
4145
- else if (sql15[j] === "'")
4194
+ else if (sql17[j] === "'")
4146
4195
  break;
4147
4196
  else
4148
4197
  j++;
4149
4198
  }
4150
- wipe(i, Math.min(j + 1, sql15.length));
4199
+ wipe(i, Math.min(j + 1, sql17.length));
4151
4200
  i = j + 1;
4152
4201
  } else if (c === '"') {
4153
- const end = sql15.indexOf('"', i + 1);
4154
- i = end === -1 ? sql15.length : end + 1;
4202
+ const end = sql17.indexOf('"', i + 1);
4203
+ i = end === -1 ? sql17.length : end + 1;
4155
4204
  } else if (c === "$") {
4156
- const tag = /^\$[A-Za-z_]*\$/.exec(sql15.slice(i));
4205
+ const tag = /^\$[A-Za-z_]*\$/.exec(sql17.slice(i));
4157
4206
  if (tag) {
4158
4207
  wipe(i, i + tag[0].length);
4159
4208
  i += tag[0].length;
@@ -4173,8 +4222,8 @@ function splitName(raw) {
4173
4222
  const unq = (p) => p.startsWith('"') ? p.slice(1, -1).replace(/""/g, '"') : p.toLowerCase();
4174
4223
  return parts.length === 2 ? { schema: unq(parts[0]), name: unq(parts[1]) } : { schema: null, name: unq(parts[0] ?? "") };
4175
4224
  }
4176
- function lintMigrationSql(name, sql15, file = "migration.sql") {
4177
- const text2 = blank(sql15);
4225
+ function lintMigrationSql(name, sql17, file = "migration.sql") {
4226
+ const text2 = blank(sql17);
4178
4227
  const prefix = `mod_${name}`;
4179
4228
  const own = (raw) => {
4180
4229
  const { schema, name: n } = splitName(raw);
@@ -4993,20 +5042,20 @@ var LOGS_RETENTION_DAYS = 30;
4993
5042
  import { createHash as createHash8 } from "node:crypto";
4994
5043
 
4995
5044
  // packages/insights/dist/resolve.server.js
4996
- import { and as and16, eq as eq16, isNull as isNull6 } from "drizzle-orm";
5045
+ import { and as and18, eq as eq18, isNull as isNull7 } from "drizzle-orm";
4997
5046
 
4998
5047
  // packages/insights/dist/signals.server.js
4999
- import { sql as sql10 } from "drizzle-orm";
5048
+ import { sql as sql12 } from "drizzle-orm";
5000
5049
  var SIGNAL_TTL_SEC = (LOGS_RETENTION_DAYS + 1) * 24 * 60 * 60;
5001
- function utcDay(d = /* @__PURE__ */ new Date()) {
5050
+ function utcDay2(d = /* @__PURE__ */ new Date()) {
5002
5051
  return d.toISOString().slice(0, 10);
5003
5052
  }
5004
5053
 
5005
5054
  // packages/insights/dist/beacon.server.js
5006
- import { sql as sql11 } from "drizzle-orm";
5055
+ import { sql as sql13 } from "drizzle-orm";
5007
5056
 
5008
5057
  // packages/insights/dist/query.server.js
5009
- import { and as and17, desc as desc7, eq as eq17, gte as gte2 } from "drizzle-orm";
5058
+ import { and as and19, desc as desc7, eq as eq19, gte as gte4 } from "drizzle-orm";
5010
5059
  var DAY_MS = 24 * 60 * 60 * 1e3;
5011
5060
 
5012
5061
  // packages/insights/dist/logs.js
@@ -5022,10 +5071,10 @@ function statusClass(status) {
5022
5071
  }
5023
5072
 
5024
5073
  // packages/insights/dist/logs.server.js
5025
- import { and as and18, desc as desc8, eq as eq18, gte as gte3, lt as lt5, sql as sql13 } from "drizzle-orm";
5074
+ import { and as and20, desc as desc8, eq as eq20, gte as gte5, lt as lt5, sql as sql15 } from "drizzle-orm";
5026
5075
 
5027
5076
  // packages/insights/dist/module-stats.server.js
5028
- import { sql as sql12 } from "drizzle-orm";
5077
+ import { sql as sql14 } from "drizzle-orm";
5029
5078
  var MODULE_STATS_FLUSH_SEC = 60;
5030
5079
  var MODULE_STATS_TTL_SEC = (LOGS_RETENTION_DAYS + 1) * 24 * 60 * 60;
5031
5080
  var sharedRedis = () => getRedis();
@@ -5038,7 +5087,7 @@ function flushMarkerKey(appId, day) {
5038
5087
  async function recordModuleRequest(appId, module, status, opts = {}) {
5039
5088
  try {
5040
5089
  const r = (opts.redis ?? sharedRedis)();
5041
- const day = utcDay(opts.now ?? /* @__PURE__ */ new Date());
5090
+ const day = utcDay2(opts.now ?? /* @__PURE__ */ new Date());
5042
5091
  const key = moduleCountersKey(appId, day);
5043
5092
  if (await r.hincrby(key, `${module}:${statusClass(status)}`, 1) === 1)
5044
5093
  await r.expire(key, MODULE_STATS_TTL_SEC);
@@ -5055,10 +5104,10 @@ function moduleStatRows(appId, day, counters) {
5055
5104
  const at = field.lastIndexOf(":");
5056
5105
  const module = field.slice(0, at);
5057
5106
  const cls = field.slice(at + 1);
5058
- const count2 = Number(raw);
5059
- if (at <= 0 || !STATUS_CLASSES.includes(cls) || !Number.isInteger(count2) || count2 <= 0)
5107
+ const count3 = Number(raw);
5108
+ if (at <= 0 || !STATUS_CLASSES.includes(cls) || !Number.isInteger(count3) || count3 <= 0)
5060
5109
  continue;
5061
- rows.push({ appId, module, statusClass: cls, day, count: count2 });
5110
+ rows.push({ appId, module, statusClass: cls, day, count: count3 });
5062
5111
  }
5063
5112
  return rows;
5064
5113
  }
@@ -5067,7 +5116,7 @@ async function upsertModuleStats(rows) {
5067
5116
  return;
5068
5117
  await getDb().insert(moduleRequestStats).values(rows).onConflictDoUpdate({
5069
5118
  target: [moduleRequestStats.appId, moduleRequestStats.module, moduleRequestStats.statusClass, moduleRequestStats.day],
5070
- set: { count: sql12`greatest(${moduleRequestStats.count}, excluded.count)` }
5119
+ set: { count: sql14`greatest(${moduleRequestStats.count}, excluded.count)` }
5071
5120
  });
5072
5121
  }
5073
5122
  async function flushModuleRequests(appId, day, opts = {}) {
@@ -5082,12 +5131,12 @@ async function flushModuleRequests(appId, day, opts = {}) {
5082
5131
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
5083
5132
 
5084
5133
  // packages/insights/dist/prune.server.js
5085
- import { lt as lt6, sql as sql14 } from "drizzle-orm";
5134
+ import { lt as lt6, sql as sql16 } from "drizzle-orm";
5086
5135
  var DEFAULT_LOGS_PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
5087
5136
  var DAY_MS3 = 24 * 60 * 60 * 1e3;
5088
5137
 
5089
5138
  // packages/modules/dist/runtime.js
5090
- import { and as and19, eq as eq19, inArray as inArray9 } from "drizzle-orm";
5139
+ import { and as and21, eq as eq21, inArray as inArray9 } from "drizzle-orm";
5091
5140
  import { z } from "zod";
5092
5141
  function redisRateLimiter(redis) {
5093
5142
  return async (key, max, windowMs) => {
@@ -5134,7 +5183,7 @@ function smtpEmailTransport(log, env = process.env) {
5134
5183
  };
5135
5184
  }
5136
5185
  async function appOwnerEmails(db2, workspaceId) {
5137
- const rows = await db2.select({ email: users.email }).from(memberships).innerJoin(users, eq19(users.id, memberships.userId)).where(and19(eq19(memberships.workspaceId, workspaceId), inArray9(memberships.role, ["editor", "workspace-admin"])));
5186
+ const rows = await db2.select({ email: users.email }).from(memberships).innerJoin(users, eq21(users.id, memberships.userId)).where(and21(eq21(memberships.workspaceId, workspaceId), inArray9(memberships.role, ["editor", "workspace-admin"])));
5138
5187
  return rows.map((r) => r.email);
5139
5188
  }
5140
5189
  function uniqueKeyOf(value, unique) {
@@ -5291,7 +5340,7 @@ var ModuleRuntime = class {
5291
5340
  return out;
5292
5341
  const plan = await this.deps.limits.fromPlan?.(workspaceId) ?? null;
5293
5342
  const env = this.deps.limits.defaults();
5294
- const rows = workspaceId ? await this.deps.db().select({ module: workspaceModules.module, enabledBy: workspaceModules.enabledBy, enabledAt: workspaceModules.enabledAt }).from(workspaceModules).where(and19(eq19(workspaceModules.workspaceId, workspaceId), inArray9(workspaceModules.module, modules.map((m) => m.name)))) : [];
5343
+ const rows = workspaceId ? await this.deps.db().select({ module: workspaceModules.module, enabledBy: workspaceModules.enabledBy, enabledAt: workspaceModules.enabledAt }).from(workspaceModules).where(and21(eq21(workspaceModules.workspaceId, workspaceId), inArray9(workspaceModules.module, modules.map((m) => m.name)))) : [];
5295
5344
  const byModule = new Map(rows.map((r) => [r.module, { enabledBy: r.enabledBy, enabledAt: r.enabledAt }]));
5296
5345
  for (const m of modules) {
5297
5346
  const key = moduleEnabledLimitName(m.name);
@@ -5371,7 +5420,7 @@ var ModuleRuntime = class {
5371
5420
  });
5372
5421
  }
5373
5422
  return this.deps.db().transaction(async (tx) => {
5374
- const changed = input.enabled ? (await tx.insert(workspaceModules).values({ workspaceId: input.workspaceId, module: m.name, enabledBy: input.actorUserId }).onConflictDoNothing().returning({ module: workspaceModules.module })).length > 0 : (await tx.delete(workspaceModules).where(and19(eq19(workspaceModules.workspaceId, input.workspaceId), eq19(workspaceModules.module, m.name))).returning({ module: workspaceModules.module })).length > 0;
5423
+ const changed = input.enabled ? (await tx.insert(workspaceModules).values({ workspaceId: input.workspaceId, module: m.name, enabledBy: input.actorUserId }).onConflictDoNothing().returning({ module: workspaceModules.module })).length > 0 : (await tx.delete(workspaceModules).where(and21(eq21(workspaceModules.workspaceId, input.workspaceId), eq21(workspaceModules.module, m.name))).returning({ module: workspaceModules.module })).length > 0;
5375
5424
  if (changed) {
5376
5425
  await writeAudit({
5377
5426
  workspaceId: input.workspaceId,
@@ -5444,7 +5493,7 @@ var ModuleRuntime = class {
5444
5493
  return null;
5445
5494
  const deps = this.deps;
5446
5495
  const db2 = deps.db();
5447
- const [row] = await db2.select({ id: apps.id, slug: apps.slug, workspaceId: apps.workspaceId, deletedAt: apps.deletedAt, lockedReason: apps.lockedReason }).from(apps).where(eq19(apps.id, appId)).limit(1);
5496
+ const [row] = await db2.select({ id: apps.id, slug: apps.slug, workspaceId: apps.workspaceId, deletedAt: apps.deletedAt, lockedReason: apps.lockedReason }).from(apps).where(eq21(apps.id, appId)).limit(1);
5448
5497
  if (!row || row.deletedAt || row.lockedReason)
5449
5498
  return null;
5450
5499
  const app = { id: row.id, slug: row.slug, workspaceId: row.workspaceId };
@@ -5916,7 +5965,7 @@ var ModuleRuntime = class {
5916
5965
  async workspaceOf(appId) {
5917
5966
  if (this.optInModules().length === 0)
5918
5967
  return "";
5919
- const [row] = await this.deps.db().select({ workspaceId: apps.workspaceId }).from(apps).where(eq19(apps.id, appId)).limit(1);
5968
+ const [row] = await this.deps.db().select({ workspaceId: apps.workspaceId }).from(apps).where(eq21(apps.id, appId)).limit(1);
5920
5969
  return row?.workspaceId ?? "";
5921
5970
  }
5922
5971
  requireModule(name) {
@@ -6047,7 +6096,7 @@ var ModuleRuntime = class {
6047
6096
  const waiting = await this.pendingSummary(app.id);
6048
6097
  if (waiting.length === 0)
6049
6098
  return;
6050
- const [row] = await deps.db().select({ name: apps.name }).from(apps).where(eq19(apps.id, app.id)).limit(1);
6099
+ const [row] = await deps.db().select({ name: apps.name }).from(apps).where(eq21(apps.id, app.id)).limit(1);
6051
6100
  const mail = pendingMail({
6052
6101
  appName: row?.name ?? app.slug,
6053
6102
  modules: waiting.map((w) => ({ ...w, confirmUrl: confirmUrl(deps.env, app.workspaceSlug, app.slug, w.module) }))
@@ -6241,11 +6290,11 @@ var ModuleRuntime = class {
6241
6290
  }
6242
6291
  /** get_logs `requests`: one response of a matched route of an active module (fire-and-forget). */
6243
6292
  countRequest(appId, module, status) {
6244
- const count2 = this.deps.requestStats;
6245
- if (!count2)
6293
+ const count3 = this.deps.requestStats;
6294
+ if (!count3)
6246
6295
  return;
6247
6296
  try {
6248
- void Promise.resolve(count2(appId, module, status)).catch(() => void 0);
6297
+ void Promise.resolve(count3(appId, module, status)).catch(() => void 0);
6249
6298
  } catch {
6250
6299
  }
6251
6300
  }
@@ -6489,6 +6538,8 @@ export {
6489
6538
  decideAccess,
6490
6539
  workspaces,
6491
6540
  apps,
6541
+ moduleConfigs,
6542
+ getDb,
6492
6543
  noopLogger,
6493
6544
  perIpLimitKey,
6494
6545
  sniffSignature,
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ZodType, z } from 'zod';
2
2
  export { z } from 'zod';
3
- import { L as Logger, M as ModuleSecretDoc, H as HookApp, E as EndUser, D as DB, R as Rule, P as Principal, A as AccessDecision, a as ModuleLimit, b as Limits, C as ConfirmRole, c as AnyModule, d as EmailMessage, e as EmailKind, f as EmailRecipient, U as UploadedFile, g as EndUserListQuery, h as EndUserPage, i as EndUserRecord, O as OwnerFilesPage, j as OwnerFile, k as RecordsCollection, l as RecordsQuery, m as RecordsPage, S as SubmissionsQuery, n as SubmissionsPage, o as MailEnvelope, p as RateLimitResult, q as MailGuard, r as ModuleAvailability, s as ModuleDashboardEditor, t as ModuleErrorDoc, u as SdkBundle, v as ModuleServices, w as EndUserCallbackInput, x as EndUserCallbackResult } from './mail-guard.d-BKJhsVA2.js';
4
- export { B as BEACON_SCRIPT_PATH, y as BeaconScript, z as ComposedModuleParts, F as ConfirmContext, G as ConfirmItem, I as ConfirmedContext, J as DrobekModule, K as EndUserAuthority, N as EndUserCallbackApp, Q as FilesAuthority, T as MAIL_COUNTER_KEYS, V as MAIL_PAUSE_KEYS, W as MODULE_CONTRACT_VERSION, X as MODULE_ERROR_CODE_RE, Y as MODULE_NAME_RE, Z as MailAuthority, _ as MailBudgets, $ as MailClass, a0 as MailGuardConfig, a1 as MailGuardMeta, a2 as MailGuardRedis, a3 as MailPrepareInput, a4 as ModuleAppView, a5 as ModuleComposeInput, a6 as ModuleContext, a7 as ModuleDashboard, a8 as ModuleHooks, a9 as ModuleMigrations, aa as ModuleRequest, ab as ModuleResponse, ac as ModuleRouter, ad as ModuleSdk, ae as ModuleSkill, af as ModuleSlot, ag as OwnerSubmission, ah as OwnerView, ai as RECORDS_IMPORT_MAX_ROWS, aj as RecordsAuthority, ak as RecordsView, al as RouteHandler, am as RouteOptions, an as RouteRateLimit, ao as SDK_PATH, ap as SDK_TYPES_PATH, aq as SLOT_NAME_RE, ar as SubmissionsAuthority, as as buildBeaconScript, at as buildSdk, au as defineModule, av as inlineSpecifier, aw as isDefinedModule, ax as mailAppCounterKey, ay as mailBudgets, az as mailGuardConfigFromEnv, aA as memoryMailGuard, aB as memoryMailGuardRedis, aC as normalizeConfirmItems, aD as redisMailGuard, aE as respond, aF as sdkDeclarations } from './mail-guard.d-BKJhsVA2.js';
3
+ import { L as Logger, M as ModuleSecretDoc, H as HookApp, E as EndUser, D as DB, R as Rule, P as Principal, A as AccessDecision, a as ModuleLimit, b as Limits, C as ConfirmRole, c as AnyModule, d as EmailMessage, e as EmailKind, f as EmailRecipient, U as UploadedFile, g as EndUserListQuery, h as EndUserPage, i as EndUserRecord, O as OwnerFilesPage, j as OwnerFile, k as RecordsCollection, l as RecordsQuery, m as RecordsPage, S as SubmissionsQuery, n as SubmissionsPage, o as MailEnvelope, p as RateLimitResult, q as MailGuard, r as ModuleAvailability, s as ModuleDashboardEditor, t as ModuleErrorDoc, u as SdkBundle, v as ModuleServices, w as EndUserCallbackInput, x as EndUserCallbackResult } from './mail-guard.d-BHuy9yy0.js';
4
+ export { B as BEACON_SCRIPT_PATH, y as BeaconScript, z as ComposedModuleParts, F as ConfirmContext, G as ConfirmItem, I as ConfirmedContext, J as DrobekModule, K as EndUserAuthority, N as EndUserCallbackApp, Q as FilesAuthority, T as MAIL_COUNTER_KEYS, V as MAIL_PAUSE_KEYS, W as MODULE_CONTRACT_VERSION, X as MODULE_ERROR_CODE_RE, Y as MODULE_NAME_RE, Z as MailAuthority, _ as MailBudgets, $ as MailClass, a0 as MailGuardConfig, a1 as MailGuardMeta, a2 as MailGuardRedis, a3 as MailPrepareInput, a4 as ModuleAppView, a5 as ModuleComposeInput, a6 as ModuleContext, a7 as ModuleDashboard, a8 as ModuleHooks, a9 as ModuleMigrations, aa as ModuleRequest, ab as ModuleResponse, ac as ModuleRouter, ad as ModuleSdk, ae as ModuleSkill, af as ModuleSlot, ag as OwnerSubmission, ah as OwnerView, ai as RECORDS_IMPORT_MAX_ROWS, aj as RecordsAuthority, ak as RecordsView, al as RouteHandler, am as RouteOptions, an as RouteRateLimit, ao as SDK_PATH, ap as SDK_TYPES_PATH, aq as SLOT_NAME_RE, ar as SubmissionsAuthority, as as buildBeaconScript, at as buildSdk, au as defineModule, av as inlineSpecifier, aw as isDefinedModule, ax as mailAppCounterKey, ay as mailBudgets, az as mailGuardConfigFromEnv, aA as memoryMailGuard, aB as memoryMailGuardRedis, aC as normalizeConfirmItems, aD as redisMailGuard, aE as respond, aF as sdkDeclarations } from './mail-guard.d-BHuy9yy0.js';
5
5
  export { SdkCore } from '@drobek/sdk';
6
6
  import { Readable } from 'node:stream';
7
7
  import { Redis } from 'ioredis';
@@ -1600,5 +1600,35 @@ declare function activeModules(): Promise<ModuleSummary[]>;
1600
1600
  /** Tests: install a runtime (or null to reset). */
1601
1601
  declare function setModuleRuntimeForTests(runtime: ModuleRuntime | null): void;
1602
1602
 
1603
- export { AUTH_PROVIDER_ID_RE, AUTH_RESERVED_CONFIG_KEYS, AccessDecision, AnyModule, BACKEND_IMPORT_SKILLS, CORE_ERROR_CODES, CORE_LIMITS, ConfirmRole, DB, DEFAULT_MAX_BODY_BYTES, DEFAULT_MODULES_DIR, EMAIL_PROVIDER_ID, EMAIL_RE, END_USER_COOKIE, END_USER_COOKIE_INSECURE, END_USER_SESSION_TTL_SEC, END_USER_TOKEN_RE, EmailKind, EmailMessage, EmailRecipient, EndUser, EndUserCallbackInput, EndUserCallbackResult, EndUserListQuery, EndUserPage, EndUserRecord, HookApp, LIMITS_CACHE_TTL_SEC, LIMITS_SIGNATURE_HEADER, LIMITS_TIMESTAMP_HEADER, Limits, Logger, Lru, MAX_EMAIL_TEXT, MAX_FILE_HEAD_BYTES, MODULE_ERROR_CODES, MailEnvelope, MailGuard, ModuleAvailability, ModuleDashboardEditor, ModuleError, ModuleErrorDoc, ModuleLimit, ModuleLoadError, ModuleRuntime, ModuleSecretDoc, ModuleServices, OwnerFile, OwnerFilesPage, PENDING_MAIL_WINDOW_MS, PLATFORM_SKILL_NAME, Principal, RESERVED_MODULE_NAMES, RULE_TOKENS, RateLimitResult, RecordsCollection, RecordsPage, RecordsQuery, Rule, SDK_HEADER, SECRET_MAX_BYTES, SECRET_NAME_RE, SdkBundle, SecretStoreError, SubmissionsPage, SubmissionsQuery, UploadedFile, activeModules, appOwnerEmails, authIdentitySchema, authProviderSchema, authSignedInObserverSchema, capEmailText, checkErrorCodes, checkModuleSet, checkRequires, collectContributions, composeModule, confirmUrl, cookiePrincipalResolver, createEndUserSession, createLimitsProvider, csvChunks, decideAccess, defineAuthProvider, defineSignInObserver, deleteModuleSecret, destroyEndUserSession, effectiveConfigDefaults, emailKind, endUserAuthorityOf, endUserCookieHeader, endUserCookieName, endUserCookiesSecure, endUserEpochKey, endUserSessionKey, filesAuthorityOf, generalSkillsDir, getModuleSecret, hasControlBytes, isModuleError, isReadable, isValidRule, issuePaths, jsonEqual, jsonKey, limitsProviderConfigError, loadEndUserSession, loadGeneralSkills, loadModuleRuntime, loadModuleSet, loadModules, looksLikeSvg, mailAuthorityOf, memoryRateLimiter, mergePatch, moduleDefaultsEnvName, moduleEnabledLimitName, moduleJournalTable, moduleNotEnabled, moduleRuntime, multipartBoundary, packageNameFor, parseEndUserSession, parseModuleList, parseMultipart, parseRule, parseSkillMarkdown, pendingMail, pendingMailKey, perIpLimitKey, readConfigRow, readEndUserToken, recipientRefs, recordsAuthorityOf, redisRateLimiter, renewEndUserSession, resolveRecipients, revokeEndUserSessions, ruleIsPublic, sanitizeSubject, secretsSet, secretsStatus, setModuleRuntimeForTests, setModuleSecret, signLimitsRequest, skillForImport, skillHint, smtpEmailTransport, sniffSignature, stableJson, streamMultipartFile, submissionsAuthorityOf };
1604
- export type { AppModuleState, AuthIdentity, AuthProvider, AuthProviderBeginInput, AuthProviderBeginResult, AuthProviderCallbackInput, AuthProviderSecretDoc, AuthProviderSecrets, AuthSignInEvent, AuthSignedInObserver, BoundEndUsers, BoundFiles, BoundRecords, BoundSubmissions, ConfigRow, ConfigureInput, ConfigureResult, CurrentEndUser, DecisionInput, EmailTransport, EndUserRedis, EndUserSession, LimitsProvider, LoadRuntimeOptions, LoadedModules, ModuleDashboardView, ModuleErrorBody, ModuleErrorCode, ModuleErrorSection, ModuleFacts, ModuleOrigin, ModuleSource, ModuleSummary, PendingChange, PendingMailModule, PendingView, PipelineRequest, PipelineResult, PlatformApp, PlatformRequest, PrincipalResolver, RateLimiter, RecipientSources, RuntimeDeps, SkillEntry, SkillInfo, SkillListItem, SlotContribution, SniffedType, TransportMessage, WorkspaceModuleSource, WorkspaceModuleState };
1603
+ /** Modules whose config is never copied: it points at records of the source workspace. */
1604
+ declare const NOT_COPIED_MODULES: readonly string[];
1605
+ /** The part of a saved `module` config a copy may propose, or null when nothing is left. */
1606
+ declare function configForCopy(module: string, config: unknown): Record<string, unknown> | null;
1607
+ interface DuplicateConfigsInput {
1608
+ sourceAppId: string;
1609
+ target: {
1610
+ id: string;
1611
+ slug: string;
1612
+ workspaceId: string;
1613
+ workspaceSlug: string;
1614
+ };
1615
+ actorUserId: string;
1616
+ surface: 'mcp' | 'web';
1617
+ }
1618
+ interface DuplicateConfigsResult {
1619
+ applied: string[];
1620
+ pending: {
1621
+ module: string;
1622
+ changes: string[];
1623
+ confirm_url?: string;
1624
+ }[];
1625
+ skipped: {
1626
+ module: string;
1627
+ reason: 'not_copied' | 'not_enabled' | 'invalid';
1628
+ }[];
1629
+ }
1630
+ /** Propose the source app's saved module configs to the copy (see the file comment). */
1631
+ declare function duplicateModuleConfigs(runtime: ModuleRuntime, input: DuplicateConfigsInput): Promise<DuplicateConfigsResult>;
1632
+
1633
+ export { AUTH_PROVIDER_ID_RE, AUTH_RESERVED_CONFIG_KEYS, AccessDecision, AnyModule, BACKEND_IMPORT_SKILLS, CORE_ERROR_CODES, CORE_LIMITS, ConfirmRole, DB, DEFAULT_MAX_BODY_BYTES, DEFAULT_MODULES_DIR, EMAIL_PROVIDER_ID, EMAIL_RE, END_USER_COOKIE, END_USER_COOKIE_INSECURE, END_USER_SESSION_TTL_SEC, END_USER_TOKEN_RE, EmailKind, EmailMessage, EmailRecipient, EndUser, EndUserCallbackInput, EndUserCallbackResult, EndUserListQuery, EndUserPage, EndUserRecord, HookApp, LIMITS_CACHE_TTL_SEC, LIMITS_SIGNATURE_HEADER, LIMITS_TIMESTAMP_HEADER, Limits, Logger, Lru, MAX_EMAIL_TEXT, MAX_FILE_HEAD_BYTES, MODULE_ERROR_CODES, MailEnvelope, MailGuard, ModuleAvailability, ModuleDashboardEditor, ModuleError, ModuleErrorDoc, ModuleLimit, ModuleLoadError, ModuleRuntime, ModuleSecretDoc, ModuleServices, NOT_COPIED_MODULES, OwnerFile, OwnerFilesPage, PENDING_MAIL_WINDOW_MS, PLATFORM_SKILL_NAME, Principal, RESERVED_MODULE_NAMES, RULE_TOKENS, RateLimitResult, RecordsCollection, RecordsPage, RecordsQuery, Rule, SDK_HEADER, SECRET_MAX_BYTES, SECRET_NAME_RE, SdkBundle, SecretStoreError, SubmissionsPage, SubmissionsQuery, UploadedFile, activeModules, appOwnerEmails, authIdentitySchema, authProviderSchema, authSignedInObserverSchema, capEmailText, checkErrorCodes, checkModuleSet, checkRequires, collectContributions, composeModule, configForCopy, confirmUrl, cookiePrincipalResolver, createEndUserSession, createLimitsProvider, csvChunks, decideAccess, defineAuthProvider, defineSignInObserver, deleteModuleSecret, destroyEndUserSession, duplicateModuleConfigs, effectiveConfigDefaults, emailKind, endUserAuthorityOf, endUserCookieHeader, endUserCookieName, endUserCookiesSecure, endUserEpochKey, endUserSessionKey, filesAuthorityOf, generalSkillsDir, getModuleSecret, hasControlBytes, isModuleError, isReadable, isValidRule, issuePaths, jsonEqual, jsonKey, limitsProviderConfigError, loadEndUserSession, loadGeneralSkills, loadModuleRuntime, loadModuleSet, loadModules, looksLikeSvg, mailAuthorityOf, memoryRateLimiter, mergePatch, moduleDefaultsEnvName, moduleEnabledLimitName, moduleJournalTable, moduleNotEnabled, moduleRuntime, multipartBoundary, packageNameFor, parseEndUserSession, parseModuleList, parseMultipart, parseRule, parseSkillMarkdown, pendingMail, pendingMailKey, perIpLimitKey, readConfigRow, readEndUserToken, recipientRefs, recordsAuthorityOf, redisRateLimiter, renewEndUserSession, resolveRecipients, revokeEndUserSessions, ruleIsPublic, sanitizeSubject, secretsSet, secretsStatus, setModuleRuntimeForTests, setModuleSecret, signLimitsRequest, skillForImport, skillHint, smtpEmailTransport, sniffSignature, stableJson, streamMultipartFile, submissionsAuthorityOf };
1634
+ export type { AppModuleState, AuthIdentity, AuthProvider, AuthProviderBeginInput, AuthProviderBeginResult, AuthProviderCallbackInput, AuthProviderSecretDoc, AuthProviderSecrets, AuthSignInEvent, AuthSignedInObserver, BoundEndUsers, BoundFiles, BoundRecords, BoundSubmissions, ConfigRow, ConfigureInput, ConfigureResult, CurrentEndUser, DecisionInput, DuplicateConfigsInput, DuplicateConfigsResult, EmailTransport, EndUserRedis, EndUserSession, LimitsProvider, LoadRuntimeOptions, LoadedModules, ModuleDashboardView, ModuleErrorBody, ModuleErrorCode, ModuleErrorSection, ModuleFacts, ModuleOrigin, ModuleSource, ModuleSummary, PendingChange, PendingMailModule, PendingView, PipelineRequest, PipelineResult, PlatformApp, PlatformRequest, PrincipalResolver, RateLimiter, RecipientSources, RuntimeDeps, SkillEntry, SkillInfo, SkillListItem, SlotContribution, SniffedType, TransportMessage, WorkspaceModuleSource, WorkspaceModuleState };
package/dist/index.js CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  endUserSessionKey,
67
67
  filesAuthorityOf,
68
68
  generalSkillsDir,
69
+ getDb,
69
70
  getModuleSecret,
70
71
  hasControlBytes,
71
72
  inlineSpecifier,
@@ -91,6 +92,7 @@ import {
91
92
  memoryMailGuardRedis,
92
93
  memoryRateLimiter,
93
94
  mergePatch,
95
+ moduleConfigs,
94
96
  moduleDefaultsEnvName,
95
97
  moduleEnabledLimitName,
96
98
  moduleJournalTable,
@@ -132,7 +134,7 @@ import {
132
134
  stableJson,
133
135
  streamMultipartFile,
134
136
  submissionsAuthorityOf
135
- } from "./chunk-WHKPW5MP.js";
137
+ } from "./chunk-B7PEIWK7.js";
136
138
 
137
139
  // packages/modules/dist/index.js
138
140
  import { z as z2 } from "zod";
@@ -227,6 +229,63 @@ async function* csvChunks(lines, chunkChars = CSV_CHUNK_CHARS) {
227
229
  if (chunk)
228
230
  yield chunk;
229
231
  }
232
+
233
+ // packages/modules/dist/duplicate.js
234
+ import { eq } from "drizzle-orm";
235
+ var NOT_COPIED_MODULES = ["proxy"];
236
+ var EMAIL_RE2 = /[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+/;
237
+ function stripForCopy(value) {
238
+ if (value === null)
239
+ return void 0;
240
+ if (typeof value === "string")
241
+ return EMAIL_RE2.test(value) ? void 0 : value;
242
+ if (Array.isArray(value)) {
243
+ return value.map(stripForCopy).filter((v) => v !== void 0);
244
+ }
245
+ if (typeof value === "object") {
246
+ const out = {};
247
+ for (const [k, v] of Object.entries(value)) {
248
+ const kept = stripForCopy(v);
249
+ if (kept !== void 0)
250
+ out[k] = kept;
251
+ }
252
+ return out;
253
+ }
254
+ return value;
255
+ }
256
+ function configForCopy(module, config) {
257
+ if (NOT_COPIED_MODULES.includes(module))
258
+ return null;
259
+ if (!config || typeof config !== "object" || Array.isArray(config))
260
+ return null;
261
+ const out = stripForCopy(config);
262
+ return Object.keys(out).length > 0 ? out : null;
263
+ }
264
+ async function duplicateModuleConfigs(runtime, input) {
265
+ const rows = await getDb().select({ module: moduleConfigs.module, config: moduleConfigs.config }).from(moduleConfigs).where(eq(moduleConfigs.appId, input.sourceAppId)).orderBy(moduleConfigs.module);
266
+ const out = { applied: [], pending: [], skipped: [] };
267
+ for (const row of rows) {
268
+ const patch = configForCopy(row.module, row.config);
269
+ if (!patch) {
270
+ if (NOT_COPIED_MODULES.includes(row.module))
271
+ out.skipped.push({ module: row.module, reason: "not_copied" });
272
+ continue;
273
+ }
274
+ try {
275
+ const r = await runtime.configure({ app: input.target, module: row.module, patch, actorUserId: input.actorUserId, surface: input.surface });
276
+ if (r.pending_confirmation.length > 0) {
277
+ out.pending.push({ module: row.module, changes: r.pending_confirmation, ...r.confirm_url ? { confirm_url: r.confirm_url } : {} });
278
+ } else {
279
+ out.applied.push(row.module);
280
+ }
281
+ } catch (err) {
282
+ if (!isModuleError(err))
283
+ throw err;
284
+ out.skipped.push({ module: row.module, reason: err.code === "module_not_enabled" || err.code === "not_found" ? "not_enabled" : "invalid" });
285
+ }
286
+ }
287
+ return out;
288
+ }
230
289
  export {
231
290
  AUTH_PROVIDER_ID_RE,
232
291
  AUTH_RESERVED_CONFIG_KEYS,
@@ -257,6 +316,7 @@ export {
257
316
  ModuleError,
258
317
  ModuleLoadError,
259
318
  ModuleRuntime,
319
+ NOT_COPIED_MODULES,
260
320
  PENDING_MAIL_WINDOW_MS,
261
321
  PLATFORM_SKILL_NAME,
262
322
  RECORDS_IMPORT_MAX_ROWS,
@@ -282,6 +342,7 @@ export {
282
342
  checkRequires,
283
343
  collectContributions,
284
344
  composeModule,
345
+ configForCopy,
285
346
  confirmUrl,
286
347
  cookiePrincipalResolver,
287
348
  createEndUserSession,
@@ -293,6 +354,7 @@ export {
293
354
  defineSignInObserver,
294
355
  deleteModuleSecret,
295
356
  destroyEndUserSession,
357
+ duplicateModuleConfigs,
296
358
  effectiveConfigDefaults,
297
359
  emailKind,
298
360
  endUserAuthorityOf,
@@ -185,9 +185,16 @@ declare const apps = pgTable(
185
185
  galleryDescription: text('gallery_description'),
186
186
  /** NSO-340: a super-admin hid the gallery entry (non-null = hidden, whatever the owner sets). */
187
187
  galleryHiddenAt: timestamp('gallery_hidden_at'),
188
+ /** NSO-340: the owner lets signed-in people duplicate the listed app into their own workspace. */
189
+ galleryAllowDuplicate: boolean('gallery_allow_duplicate').notNull().default(false),
190
+ /** NSO-340: the gallery app this one was duplicated from (null when the source is gone or none). */
191
+ duplicatedFromAppId: text('duplicated_from_app_id').references((): AnyPgColumn => apps.id, { onDelete: 'set null' }),
192
+ /** The source's slug at duplication time, kept when the source is deleted. */
193
+ duplicatedFromSlug: text('duplicated_from_slug'),
188
194
  },
189
195
  (t) => [
190
196
  uniqueIndex('apps_slug_uq').on(t.slug),
197
+ index('apps_duplicated_from_idx').on(t.duplicatedFromAppId).where(sql`${t.duplicatedFromAppId} IS NOT NULL`),
191
198
  // The public gallery page reads listed apps newest-published first (NSO-340).
192
199
  index('apps_gallery_idx').on(t.publishedAt.desc(), t.slug.desc()).where(sql`${t.galleryListed}`),
193
200
  index('apps_workspace_idx').on(t.workspaceId),
@@ -202,6 +209,34 @@ declare const apps = pgTable(
202
209
  ]
203
210
  );
204
211
 
212
+ /** NSO-340: a signed-in drobek account likes a gallery app — one row per account and app. */
213
+ declare const galleryLikes = pgTable(
214
+ 'gallery_likes',
215
+ {
216
+ appId: text('app_id')
217
+ .notNull()
218
+ .references(() => apps.id, { onDelete: 'cascade' }),
219
+ userId: text('user_id')
220
+ .notNull()
221
+ .references(() => users.id, { onDelete: 'cascade' }),
222
+ createdAt: timestamp('created_at').notNull().defaultNow(),
223
+ },
224
+ (t) => [primaryKey({ columns: [t.appId, t.userId] })]
225
+ );
226
+
227
+ /** NSO-340: opens of a gallery app through the gallery's counting link, per UTC day (`YYYY-MM-DD`); no visitor data. */
228
+ declare const galleryOpens = pgTable(
229
+ 'gallery_opens',
230
+ {
231
+ appId: text('app_id')
232
+ .notNull()
233
+ .references(() => apps.id, { onDelete: 'cascade' }),
234
+ day: text('day').notNull(),
235
+ count: integer('count').notNull().default(0),
236
+ },
237
+ (t) => [primaryKey({ columns: [t.appId, t.day] })]
238
+ );
239
+
205
240
  /** Content-addressed file bytes, shared by every version (and app) that uses them. */
206
241
  declare const blobs = pgTable('blobs', {
207
242
  sha256: text('sha256').primaryKey(),
@@ -861,6 +896,8 @@ declare const schema_blobs: typeof blobs;
861
896
  declare const schema_bytea: typeof bytea;
862
897
  declare const schema_compileStatusEnum: typeof compileStatusEnum;
863
898
  declare const schema_domains: typeof domains;
899
+ declare const schema_galleryLikes: typeof galleryLikes;
900
+ declare const schema_galleryOpens: typeof galleryOpens;
864
901
  declare const schema_membershipRoleEnum: typeof membershipRoleEnum;
865
902
  declare const schema_memberships: typeof memberships;
866
903
  declare const schema_moduleConfigs: typeof moduleConfigs;
@@ -900,6 +937,8 @@ declare namespace schema {
900
937
  schema_bytea as bytea,
901
938
  schema_compileStatusEnum as compileStatusEnum,
902
939
  schema_domains as domains,
940
+ schema_galleryLikes as galleryLikes,
941
+ schema_galleryOpens as galleryOpens,
903
942
  schema_membershipRoleEnum as membershipRoleEnum,
904
943
  schema_memberships as memberships,
905
944
  schema_moduleConfigs as moduleConfigs,
@@ -0,0 +1,8 @@
1
+ -- NSO-340: duplicating a gallery app. The owner opts in per app
2
+ -- (gallery_allow_duplicate); a copy remembers its source (id, and the slug
3
+ -- for when the source is deleted). Additive only.
4
+ ALTER TABLE "apps" ADD COLUMN "gallery_allow_duplicate" boolean DEFAULT false NOT NULL;--> statement-breakpoint
5
+ ALTER TABLE "apps" ADD COLUMN "duplicated_from_app_id" text;--> statement-breakpoint
6
+ ALTER TABLE "apps" ADD COLUMN "duplicated_from_slug" text;--> statement-breakpoint
7
+ ALTER TABLE "apps" ADD CONSTRAINT "apps_duplicated_from_app_id_apps_id_fk" FOREIGN KEY ("duplicated_from_app_id") REFERENCES "public"."apps"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
8
+ CREATE INDEX "apps_duplicated_from_idx" ON "apps" USING btree ("duplicated_from_app_id") WHERE "apps"."duplicated_from_app_id" IS NOT NULL;
@@ -0,0 +1,19 @@
1
+ -- NSO-340: gallery likes (one per signed-in account and app) and opens through
2
+ -- the gallery's counting link (per app and UTC day, no visitor data).
3
+ CREATE TABLE "gallery_likes" (
4
+ "app_id" text NOT NULL,
5
+ "user_id" text NOT NULL,
6
+ "created_at" timestamp DEFAULT now() NOT NULL,
7
+ CONSTRAINT "gallery_likes_app_id_user_id_pk" PRIMARY KEY("app_id","user_id")
8
+ );
9
+ --> statement-breakpoint
10
+ CREATE TABLE "gallery_opens" (
11
+ "app_id" text NOT NULL,
12
+ "day" text NOT NULL,
13
+ "count" integer DEFAULT 0 NOT NULL,
14
+ CONSTRAINT "gallery_opens_app_id_day_pk" PRIMARY KEY("app_id","day")
15
+ );
16
+ --> statement-breakpoint
17
+ ALTER TABLE "gallery_likes" ADD CONSTRAINT "gallery_likes_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
18
+ ALTER TABLE "gallery_likes" ADD CONSTRAINT "gallery_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
19
+ ALTER TABLE "gallery_opens" ADD CONSTRAINT "gallery_opens_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;
@@ -162,6 +162,20 @@
162
162
  "when": 1790496410618,
163
163
  "tag": "0027_publish_block",
164
164
  "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 28,
168
+ "version": "7",
169
+ "when": 1790525749936,
170
+ "tag": "0028_gallery_duplicate",
171
+ "breakpoints": true
172
+ },
173
+ {
174
+ "idx": 29,
175
+ "version": "7",
176
+ "when": 1790525800000,
177
+ "tag": "0029_gallery_likes",
178
+ "breakpoints": true
165
179
  }
166
180
  ]
167
181
  }
package/dist/testing.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  runRoute,
24
24
  sanitizeSubject,
25
25
  workspaces
26
- } from "./chunk-WHKPW5MP.js";
26
+ } from "./chunk-B7PEIWK7.js";
27
27
 
28
28
  // packages/modules/dist/testing.js
29
29
  import { existsSync } from "node:fs";
@@ -63,14 +63,28 @@ var TOOL_DOCS = [
63
63
  returns: "{ app_id, name, slug, workspace, version:1, compile:{ok,errors,warnings}, preview_url, briefing, skills:[{name,use_when}] }",
64
64
  example: { name: "Shift planner", template: "react-ts" }
65
65
  },
66
+ {
67
+ name: "duplicate_app",
68
+ title: "Duplicate a gallery app",
69
+ scope: "write (editor+ role in the target workspace)",
70
+ description: "Copy an app from this server's public gallery into a workspace of the user \u2014 only an app whose owner allows duplicates (the gallery shows it as duplicable). Call it when the user asks to copy, fork or duplicate a gallery app. The copy is a new, unpublished app whose version 1 holds the source's PUBLISHED files, and it remembers where it came from (get_app `duplicated_from`). The source's module settings are proposed to the copy through the normal confirmation flow: anything that needs a confirmation waits on the new app's Modules page (`modules.pending[].confirm_url` \u2014 tell the user), and e-mail addresses and proxy upstreams are dropped. Never copied: secrets, data, end users, uploads, app assets, domains and the gallery listing. Refused when the server has no gallery (gallery_disabled), the app is not in the gallery (not_found), its owner does not allow copies (not_duplicable), the user made DUPLICATES_PER_USER_HOUR copies within the last hour (rate_limited) or the workspace is full (limit_exceeded); a `from` address that is not this server's is invalid_params. The same copy is on the dashboard at /duplicate/<slug>.",
71
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
72
+ fields: [
73
+ { name: "from", type: "string", required: true, description: "The gallery app on this server: its slug, its address (published or --preview host, or a verified custom domain) or this dashboard's /duplicate/<slug> URL. An address of another server is invalid_params." },
74
+ { name: "workspace", type: "string (optional)", required: false, description: "Workspace slug for the copy (editor+); defaults to your personal workspace." },
75
+ { name: "name", type: "string (optional, \u2264 80 chars)", required: false, description: 'Name of the copy; default "<name> copy". The slug is derived from it.' }
76
+ ],
77
+ returns: "{ app_id, slug, workspace, version:1, from, preview_url, modules:{ applied:[module], pending:[{module,changes,confirm_url}], skipped:[{module,reason}] }, note? }",
78
+ example: { from: "pixel-wall", name: "My pixel wall" }
79
+ },
66
80
  {
67
81
  name: "get_app",
68
82
  title: "Get an app",
69
83
  scope: "read (any role in the workspace)",
70
- description: "Snapshot of one app: everything list_apps shows plus the briefing, the source files of the latest version ({path,size,sha256}), the last 20 versions (number, created_at, actor_kind, reasoning, compile_status), the latest compile errors, the platform modules (per module: whether it is enabled for the app's workspace \u2014 an opt-in module the operator has not enabled says enabled:false and cannot be used \u2014, its effective config, whether a change waits for the owner's confirmation, which secrets are set \u2014 names and hasSecret only, never values \u2014 and the module's info, e.g. proxy: the workspace upstreams with registered/assigned/call/hasSecret), the skills list (without the opt-in modules that are off for the workspace), the public gallery state (listed, description, hidden_by_admin, visible \u2014 or enabled:false when the server has no gallery), the custom domains in short (host, status pending | verified, primary \u2014 list_domains has their DNS records), `can_publish` (+ `publish_contact` when the workspace may not publish: the operator blocked it or has not approved it yet) and the workspace's `publishing` state (default | allowed | blocked), and the write lock (holder + expires_at) if someone holds it. Use it to re-orient before editing.",
84
+ description: "Snapshot of one app: everything list_apps shows plus the briefing, the source files of the latest version ({path,size,sha256}), the last 20 versions (number, created_at, actor_kind, reasoning, compile_status), the latest compile errors, the platform modules (per module: whether it is enabled for the app's workspace \u2014 an opt-in module the operator has not enabled says enabled:false and cannot be used \u2014, its effective config, whether a change waits for the owner's confirmation, which secrets are set \u2014 names and hasSecret only, never values \u2014 and the module's info, e.g. proxy: the workspace upstreams with registered/assigned/call/hasSecret), the skills list (without the opt-in modules that are off for the workspace), the public gallery state (listed, description, hidden_by_admin, visible, allow_duplicate, likes \u2014 signed-in accounts that like it \u2014 and opens through the gallery in the last 30 days; or enabled:false when the server has no gallery), `duplicated_from` (the gallery app this one was copied from, when it was), the custom domains in short (host, status pending | verified, primary \u2014 list_domains has their DNS records), `can_publish` (+ `publish_contact` when the workspace may not publish: the operator blocked it or has not approved it yet) and the workspace's `publishing` state (default | allowed | blocked), and the write lock (holder + expires_at) if someone holds it. Use it to re-orient before editing.",
71
85
  annotations: READ_ONLY,
72
86
  fields: [{ name: "app_id", type: "string", required: true, description: "The app id (from list_apps / create_app)." }],
73
- returns: '{ app_id, name, slug, workspace, preview_url, published_url?, published_version?, latest_version, compile_status, compile_errors, briefing, files:[{path,size,sha256}], versions:[{number,created_at,actor_kind,reasoning,compile_status}], modules:{<name>:{enabled,configured,config,pending,pending_confirmation?,confirm_url?,secrets?:[{name,hasSecret}],info?}}, skills:[{name,use_when}], gallery:{enabled,listed?,description?,hidden_by_admin?,visible?}, domains:[{host,status:"pending"|"verified",primary}], can_publish, publish_contact?, publishing, lock?:{holder,expires_at}, locked_by_admin?, locked_reason? }',
87
+ returns: '{ app_id, name, slug, workspace, preview_url, published_url?, published_version?, latest_version, compile_status, compile_errors, briefing, files:[{path,size,sha256}], versions:[{number,created_at,actor_kind,reasoning,compile_status}], modules:{<name>:{enabled,configured,config,pending,pending_confirmation?,confirm_url?,secrets?:[{name,hasSecret}],info?}}, skills:[{name,use_when}], gallery:{enabled,listed?,description?,hidden_by_admin?,visible?,allow_duplicate?,likes?,opens?}, duplicated_from?, domains:[{host,status:"pending"|"verified",primary}], can_publish, publish_contact?, publishing, lock?:{holder,expires_at}, locked_by_admin?, locked_reason? }',
74
88
  example: { app_id: "k3v9x0\u2026" }
75
89
  },
76
90
  {
@@ -148,7 +162,7 @@ var TOOL_DOCS = [
148
162
  name: "set_gallery_listing",
149
163
  title: "List an app in the public gallery",
150
164
  scope: "publish (editor+ role in the workspace)",
151
- description: "Show a published app in this server's public gallery (its name, a one- or two-sentence description and its production URL, visible to everyone), change that description, or take the app out of the gallery. Listing (`listed: true`) needs a published app, a plain-text `description` of at most 160 characters and `user_confirmed: true` \u2014 set it ONLY after the user explicitly said yes to exactly this listing: ask them first and show them the description. Never list an app on your own initiative. Without the confirmation the answer is user_confirmation_required and nothing changes. Unlisting (`listed: false`) needs no confirmation and works at once. Refused when the server has no gallery (gallery_disabled), when the app is not published (not_published) and when the server operator hid the app from the gallery (gallery_hidden). Unpublishing the app also takes it out of the gallery. get_app shows the current state (`gallery`).",
165
+ description: "Show a published app in this server's public gallery (its name, a one- or two-sentence description and its production URL, visible to everyone), change that description, or take the app out of the gallery. Listing (`listed: true`) needs a published app, a plain-text `description` of at most 160 characters and `user_confirmed: true` \u2014 set it ONLY after the user explicitly said yes to exactly this listing: ask them first and show them the description. Never list an app on your own initiative. Without the confirmation the answer is user_confirmation_required and nothing changes. Unlisting (`listed: false`) needs no confirmation and works at once. Refused when the server has no gallery (gallery_disabled), when the app is not published (not_published) and when the server operator hid the app from the gallery (gallery_hidden). With `allow_duplicate: true` the gallery also offers a Duplicate button: signed-in people copy the published files into their own workspace (never data, users, secrets or domains) \u2014 include that in the question to the user. Unpublishing the app also takes it out of the gallery. get_app shows the current state (`gallery`).",
152
166
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
153
167
  fields: [
154
168
  { name: "app_id", type: "string", required: true, description: "The app id." },
@@ -159,6 +173,12 @@ var TOOL_DOCS = [
159
173
  required: false,
160
174
  description: "The public description: plain text, one or two sentences."
161
175
  },
176
+ {
177
+ name: "allow_duplicate",
178
+ type: "boolean (listing only, optional)",
179
+ required: false,
180
+ description: "true lets signed-in people copy the published app into their own workspace (duplicate_app, the gallery's Duplicate button); omitted keeps the current choice. Covered by the same user_confirmed."
181
+ },
162
182
  {
163
183
  name: "user_confirmed",
164
184
  type: "boolean (listing only)",
@@ -166,7 +186,7 @@ var TOOL_DOCS = [
166
186
  description: "true ONLY after the user explicitly said yes to this listing and description."
167
187
  }
168
188
  ],
169
- returns: "{ app_id, listed, description, changed, visible, note? }",
189
+ returns: "{ app_id, listed, description, allow_duplicate, changed, visible, note? }",
170
190
  example: { app_id: "k3v9x0\u2026", listed: true, description: "Plan weekly shifts for a small team.", user_confirmed: true }
171
191
  },
172
192
  {
@@ -340,6 +360,48 @@ var TOOL_DOCS = [
340
360
  returns: "{ removed:host, was_verified, was_primary, note }",
341
361
  example: { app_id: "k3v9x0\u2026", host: "shop.example.com", user_confirmed: true }
342
362
  },
363
+ {
364
+ name: "list_upstreams",
365
+ title: "List a workspace's proxy upstreams",
366
+ scope: "read (workspace-admin role in the workspace)",
367
+ description: "The external APIs registered for the proxy module in one workspace \u2014 the dashboard's workspace \u2192 Upstreams page: per upstream its `name` (what apps call it by), `base_url`, `allowed_methods`, `allowed_path_prefixes`, `auth_type` (none | bearer | header), `auth_header_name`, `has_secret` (never the secret) and `apps` \u2014 the apps whose assignment a workspace admin confirmed. Plus `upstreams_url`, the dashboard page. Workspace admins only (forbidden otherwise); get_app \u2192 modules.proxy.info shows any editor what one app may call. Read-only.",
368
+ annotations: READ_ONLY,
369
+ fields: [{ name: "workspace", type: "string", required: true, description: "The workspace slug." }],
370
+ returns: '{ workspace, upstreams:[{ name, base_url, allowed_methods, allowed_path_prefixes, auth_type:"none"|"bearer"|"header", auth_header_name, has_secret, apps:[slug], created_at }], upstreams_url }',
371
+ example: { workspace: "acme-crew" }
372
+ },
373
+ {
374
+ name: "register_upstream",
375
+ title: "Register a proxy upstream",
376
+ scope: "write (workspace-admin role in the workspace)",
377
+ description: 'Register an external API for the proxy module in a workspace \u2014 what the dashboard\'s Upstreams page does, with the same checks: a public https or http base URL on port 80/443 (never a private address), the allowed HTTP methods and path prefixes apps may call under it. `auth_type: "none"` (an API without a key, e.g. https://pokeapi.co) registers at once. `bearer` / `header` need a key, and a key never passes through MCP: the answer is `registered: false` with `secret_url` \u2014 the Upstreams page with every field filled in; give the user that link, they paste the key and click Register. Never ask for the key in chat. A registered upstream does nothing yet: assign it to an app with configure_module(\'proxy\', { upstreams: { <name>: { rules: { call: "user" } } } }) \u2014 a workspace admin confirms that in the dashboard. A name the workspace already has answers upstream_already_registered.',
378
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
379
+ fields: [
380
+ { name: "workspace", type: "string", required: true, description: "The workspace slug." },
381
+ { name: "name", type: "string", required: true, description: "The name apps call it by, e.g. pokeapi." },
382
+ { name: "base_url", type: "string", required: true, description: "The base URL, e.g. https://pokeapi.co." },
383
+ { name: "allowed_methods", type: "string[]", required: true, description: 'e.g. ["GET"].' },
384
+ { name: "allowed_path_prefixes", type: "string[]", required: true, description: 'e.g. ["/api/v2/"].' },
385
+ { name: "auth_type", type: '"none" | "bearer" | "header"', required: true, description: "none registers now; bearer / header answer secret_url." },
386
+ { name: "auth_header_name", type: "string (header only)", required: false, description: "The header that carries the key, e.g. X-Api-Key." }
387
+ ],
388
+ returns: "{ registered:true, upstream:{ name, base_url, allowed_methods, allowed_path_prefixes, auth_type, auth_header_name, has_secret, apps, created_at }, next } \u2014 or { registered:false, name, secret_url, note } for bearer / header",
389
+ example: { workspace: "acme-crew", name: "pokeapi", base_url: "https://pokeapi.co", allowed_methods: ["GET"], allowed_path_prefixes: ["/api/v2/"], auth_type: "none" }
390
+ },
391
+ {
392
+ name: "remove_upstream",
393
+ title: "Remove a proxy upstream",
394
+ scope: "write (workspace-admin role in the workspace)",
395
+ description: "Delete a registered upstream and its stored key \u2014 the dashboard's Delete on the Upstreams page. Every app that calls it gets 404 upstream_not_registered at once, so it needs `user_confirmed: true` \u2014 set it ONLY after the user explicitly said yes to removing exactly this upstream; without it the answer is user_confirmation_required with the `apps` that use it, and nothing changes. Registering the name again creates a new record: each app's assignment then needs a new confirmation.",
396
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
397
+ fields: [
398
+ { name: "workspace", type: "string", required: true, description: "The workspace slug." },
399
+ { name: "name", type: "string", required: true, description: "A registered upstream (list_upstreams lists them)." },
400
+ { name: "user_confirmed", type: "boolean", required: false, description: "true ONLY after the user explicitly said yes to removing it." }
401
+ ],
402
+ returns: "{ removed:name, apps:[slug], note }",
403
+ example: { workspace: "acme-crew", name: "pokeapi", user_confirmed: true }
404
+ },
343
405
  {
344
406
  name: "set_workspace_publishing",
345
407
  title: "Set a workspace's publishing",
@@ -362,6 +424,16 @@ var TOOL_DOCS = [
362
424
  ];
363
425
  var TOOL_NAMES = TOOL_DOCS.map((t) => t.name);
364
426
 
427
+ // packages/agent-dx/dist/plugin.js
428
+ var PLUGIN_REPO = "freema/drobek-plugin";
429
+ var PLUGIN_REPO_URL = `https://github.com/${PLUGIN_REPO}`;
430
+ var PLUGIN_MARKETPLACE = "drobek";
431
+ var PLUGIN_NAME = "drobek";
432
+ var PLUGIN_MARKETPLACE_ADD_COMMAND = `claude plugin marketplace add ${PLUGIN_REPO}`;
433
+ var PLUGIN_INSTALL_COMMAND = `claude plugin install ${PLUGIN_NAME}@${PLUGIN_MARKETPLACE}`;
434
+ var PLUGIN_BUILD_COMMAND = `/${PLUGIN_NAME}:build-app`;
435
+ var PLUGIN_PORT_COMMAND = `/${PLUGIN_NAME}:port-artifact`;
436
+
365
437
  // packages/agent-dx/dist/errors-catalogue.js
366
438
  var ERROR_CATALOGUE = [
367
439
  // ── MCP tools (isError: true, body { code, message, hint }) ───────────────
@@ -463,10 +535,16 @@ var ERROR_CATALOGUE = [
463
535
  },
464
536
  {
465
537
  code: "gallery_disabled",
466
- surface: "MCP tool isError (set_gallery_listing)",
538
+ surface: "MCP tool isError (set_gallery_listing, duplicate_app)",
467
539
  meaning: "This server runs no public gallery (its operator left GALLERY_ENABLED off). Nothing changed.",
468
540
  fix: "Tell the user this server has no public gallery; do not retry."
469
541
  },
542
+ {
543
+ code: "not_duplicable",
544
+ surface: "MCP tool isError (duplicate_app); dashboard 403 (duplicate page)",
545
+ meaning: "The app is in the public gallery, but its owner did not allow duplicates. Nothing was created.",
546
+ fix: "Do not retry and do not copy the app another way. Tell the user its owner does not allow copies; they can build their own app with create_app."
547
+ },
470
548
  {
471
549
  code: "asset_too_large",
472
550
  surface: "MCP tool isError (create_asset_upload); upload URL 413",
@@ -545,6 +623,12 @@ var ERROR_CATALOGUE = [
545
623
  meaning: "A DNS lookup timed out or the resolver failed (SERVFAIL, network), so the records could not be checked. Nothing changed \u2014 an existing verification is kept.",
546
624
  fix: "Try verify_domain again in a few minutes; if it keeps failing, the domain's DNS servers may be down \u2014 tell the user."
547
625
  },
626
+ {
627
+ code: "upstream_already_registered",
628
+ surface: "MCP tool isError (register_upstream)",
629
+ meaning: "The workspace already has an upstream with this name. Nothing changed.",
630
+ fix: "list_upstreams shows it; use it as it is, pick another name, or remove_upstream it first (the user confirms) and register it again."
631
+ },
548
632
  {
549
633
  code: "internal_error",
550
634
  surface: "MCP tool isError",
@@ -611,9 +695,9 @@ var ERROR_CATALOGUE = [
611
695
  },
612
696
  {
613
697
  code: "rate_limited",
614
- surface: "module route 429 (DrobekError), Retry-After; MCP tool isError (create_asset_upload)",
615
- meaning: "A module limit was hit (per visitor, per user or per app \u2014 `details.limit` per `details.window_seconds`). From create_asset_upload: the app has asked for APP_ASSET_UPLOADS_PER_HOUR upload URLs within the last hour.",
616
- fix: "Show the user a message and retry after Retry-After seconds; never loop. For upload URLs: upload the files you already have URLs for, and ask for more after the hour."
698
+ surface: "module route 429 (DrobekError), Retry-After; MCP tool isError (create_asset_upload, duplicate_app); dashboard 429 (duplicate page)",
699
+ meaning: "A module limit was hit (per visitor, per user or per app \u2014 `details.limit` per `details.window_seconds`). From create_asset_upload: the app has asked for APP_ASSET_UPLOADS_PER_HOUR upload URLs within the last hour. From duplicate_app: the user made DUPLICATES_PER_USER_HOUR copies of gallery apps within the last hour (`limit`, `value`).",
700
+ fix: "Show the user a message and retry after Retry-After seconds; never loop. For upload URLs: upload the files you already have URLs for, and ask for more after the hour. For duplicate_app: tell the user to try again in an hour; do not retry."
617
701
  },
618
702
  {
619
703
  code: "payload_too_large",
@@ -757,6 +841,11 @@ var LIMITS = [
757
841
  default: "60",
758
842
  meaning: "Upload URLs one app may get per hour (create_asset_upload \u2192 rate_limited)."
759
843
  },
844
+ {
845
+ env: "DUPLICATES_PER_USER_HOUR",
846
+ default: "10",
847
+ meaning: "Copies of gallery apps one person may make per hour, duplicate_app and the dashboard together (\u2192 rate_limited)."
848
+ },
760
849
  {
761
850
  env: "tool: asset upload URL",
762
851
  default: "30 min, single use",
@@ -805,16 +894,6 @@ var DOC_PAGES = {
805
894
  licensing: "https://github.com/freema/drobek/blob/main/docs/LICENSING.md"
806
895
  };
807
896
 
808
- // packages/agent-dx/dist/plugin.js
809
- var PLUGIN_REPO = "freema/drobek-plugin";
810
- var PLUGIN_REPO_URL = `https://github.com/${PLUGIN_REPO}`;
811
- var PLUGIN_MARKETPLACE = "drobek";
812
- var PLUGIN_NAME = "drobek";
813
- var PLUGIN_MARKETPLACE_ADD_COMMAND = `claude plugin marketplace add ${PLUGIN_REPO}`;
814
- var PLUGIN_INSTALL_COMMAND = `claude plugin install ${PLUGIN_NAME}@${PLUGIN_MARKETPLACE}`;
815
- var PLUGIN_BUILD_COMMAND = `/${PLUGIN_NAME}:build-app`;
816
- var PLUGIN_PORT_COMMAND = `/${PLUGIN_NAME}:port-artifact`;
817
-
818
897
  // packages/agent-dx/dist/render.js
819
898
  var AGENT_GUIDE_URL = DOC_PAGES.agent;
820
899
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freema/drobek-modules",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "The drobek platform module contract, registry and runtime: defineModule, the ModuleContext and the test kit (@drobek/modules/testing).",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "LICENSE"
43
43
  ],
44
44
  "dependencies": {
45
- "@drobek/sdk": "npm:@freema/drobek-sdk@0.3.3",
45
+ "@drobek/sdk": "npm:@freema/drobek-sdk@0.5.0",
46
46
  "@paralleldrive/cuid2": "^2.2.2",
47
47
  "esbuild": "^0.25.0",
48
48
  "ioredis": "^5.6.0",