@freema/drobek-modules 0.4.0 → 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)
@@ -223,6 +224,8 @@ __export(schema_exports, {
223
224
  blobs: () => blobs,
224
225
  compileStatusEnum: () => compileStatusEnum,
225
226
  domains: () => domains,
227
+ galleryLikes: () => galleryLikes,
228
+ galleryOpens: () => galleryOpens,
226
229
  membershipRoleEnum: () => membershipRoleEnum,
227
230
  memberships: () => memberships,
228
231
  moduleConfigs: () => moduleConfigs,
@@ -342,9 +345,16 @@ var apps = pgTable("apps", {
342
345
  /** NSO-340: the gallery's one-line public description (plain text, ≤ 160 chars). */
343
346
  galleryDescription: text("gallery_description"),
344
347
  /** NSO-340: a super-admin hid the gallery entry (non-null = hidden, whatever the owner sets). */
345
- 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")
346
355
  }, (t) => [
347
356
  uniqueIndex("apps_slug_uq").on(t.slug),
357
+ index("apps_duplicated_from_idx").on(t.duplicatedFromAppId).where(sql`${t.duplicatedFromAppId} IS NOT NULL`),
348
358
  // The public gallery page reads listed apps newest-published first (NSO-340).
349
359
  index("apps_gallery_idx").on(t.publishedAt.desc(), t.slug.desc()).where(sql`${t.galleryListed}`),
350
360
  index("apps_workspace_idx").on(t.workspaceId),
@@ -354,6 +364,16 @@ var apps = pgTable("apps", {
354
364
  // words). A deleted app may carry its released-slug tombstone instead (0016).
355
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]+$')`)
356
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] })]);
357
377
  var blobs = pgTable("blobs", {
358
378
  sha256: text("sha256").primaryKey(),
359
379
  bytes: bytea("bytes").notNull(),
@@ -697,16 +717,16 @@ async function runJournalMigrations(opts) {
697
717
  const databaseUrl = opts.databaseUrl ?? process.env.DATABASE_URL;
698
718
  if (!databaseUrl)
699
719
  throw new Error("DATABASE_URL is required");
700
- const sql15 = postgres2(databaseUrl, { max: 1, connect_timeout: 10, onnotice: () => {
720
+ const sql17 = postgres2(databaseUrl, { max: 1, connect_timeout: 10, onnotice: () => {
701
721
  } });
702
722
  try {
703
- await migrate(drizzle2(sql15), {
723
+ await migrate(drizzle2(sql17), {
704
724
  migrationsFolder: opts.migrationsFolder,
705
725
  migrationsTable: opts.migrationsTable,
706
726
  migrationsSchema: "drizzle"
707
727
  });
708
728
  } finally {
709
- await sql15.end({ timeout: 5 });
729
+ await sql17.end({ timeout: 5 });
710
730
  }
711
731
  }
712
732
 
@@ -923,12 +943,12 @@ function looksLikeSvg(head) {
923
943
  }
924
944
  if (!doctype && matchesAt(DOCTYPE_SVG_RE, head, i)) {
925
945
  doctype = true;
926
- const gt = head.indexOf(">", i);
946
+ const gt2 = head.indexOf(">", i);
927
947
  const bracket = head.indexOf("[", i);
928
- if (gt < 0)
948
+ if (gt2 < 0)
929
949
  return false;
930
- if (bracket < 0 || gt < bracket) {
931
- i = gt + 1;
950
+ if (bracket < 0 || gt2 < bracket) {
951
+ i = gt2 + 1;
932
952
  continue;
933
953
  }
934
954
  const close = head.indexOf("]", bracket + 1);
@@ -959,11 +979,11 @@ function readCookieValue(header, name) {
959
979
  if (!header)
960
980
  return null;
961
981
  for (const part of header.split(";")) {
962
- const eq20 = part.indexOf("=");
963
- if (eq20 === -1)
982
+ const eq22 = part.indexOf("=");
983
+ if (eq22 === -1)
964
984
  continue;
965
- if (part.slice(0, eq20).trim() === name)
966
- return part.slice(eq20 + 1).trim();
985
+ if (part.slice(0, eq22).trim() === name)
986
+ return part.slice(eq22 + 1).trim();
967
987
  }
968
988
  return null;
969
989
  }
@@ -1198,6 +1218,10 @@ var AUDIT_ACTIONS = {
1198
1218
  appGalleryHidden: "app.gallery_hidden",
1199
1219
  /** NSO-340: a super-admin showed a hidden gallery entry again. */
1200
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",
1201
1225
  /** NSO-346: a super-admin enabled an opt-in platform module for the workspace (meta: module). */
1202
1226
  moduleWorkspaceEnable: "module.workspace_enable",
1203
1227
  /** NSO-346: a super-admin disabled an opt-in platform module for the workspace (meta: module). */
@@ -1244,7 +1268,7 @@ async function writeAudit(entry, executor) {
1244
1268
  }
1245
1269
 
1246
1270
  // packages/audit/dist/read.server.js
1247
- import { and, desc, eq, lt, or } from "drizzle-orm";
1271
+ import { and, desc, eq, gte, lt, or } from "drizzle-orm";
1248
1272
 
1249
1273
  // packages/audit/dist/retention.server.js
1250
1274
  import { lt as lt2 } from "drizzle-orm";
@@ -1269,7 +1293,7 @@ var DEFAULT_APPS_MAX_PER_WORKSPACE = 50;
1269
1293
 
1270
1294
  // packages/apps/dist/versions.server.js
1271
1295
  import { createHash as createHash3 } from "node:crypto";
1272
- 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";
1273
1297
 
1274
1298
  // packages/compile/dist/compiler.js
1275
1299
  import { mkdtempSync } from "node:fs";
@@ -1941,7 +1965,8 @@ var reportedAppColumns = {
1941
1965
  name: apps.name,
1942
1966
  workspaceId: apps.workspaceId,
1943
1967
  workspaceSlug: workspaces.slug,
1944
- lockedReason: apps.lockedReason
1968
+ lockedReason: apps.lockedReason,
1969
+ published: sql2`(${apps.publishedVersionId} IS NOT NULL)`.mapWith(Boolean)
1945
1970
  };
1946
1971
  var SCAN_MAX_FILE = 1024 * 1024;
1947
1972
  var SCAN_MAX_TOTAL = 4 * 1024 * 1024;
@@ -2389,7 +2414,7 @@ var CONTROL_RE = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+", "
2389
2414
 
2390
2415
  // packages/apps/dist/gallery.server.js
2391
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";
2392
- var itemColumns = {
2417
+ var baseColumns = {
2393
2418
  slug: apps.slug,
2394
2419
  name: apps.name,
2395
2420
  description: apps.galleryDescription,
@@ -2399,9 +2424,18 @@ var itemColumns = {
2399
2424
  // or module runtime dependency is needed by this public core API.
2400
2425
  modules: sql8`(select coalesce(jsonb_agg(${moduleConfigs.module} order by ${moduleConfigs.module}), '[]'::jsonb)
2401
2426
  from ${moduleConfigs} where ${moduleConfigs.appId} = ${apps.id}
2402
- and jsonb_typeof(${moduleConfigs.config}) = 'object' and ${moduleConfigs.config} <> '{}'::jsonb)`
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)`
2403
2431
  };
2404
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";
2438
+
2405
2439
  // packages/modules/dist/limits.js
2406
2440
  var LIMITS_CACHE_TTL_SEC = 60;
2407
2441
  var LIMITS_PROVIDER_TIMEOUT_MS = 2e3;
@@ -2699,7 +2733,7 @@ function cookiePrincipalResolver(opts) {
2699
2733
  }
2700
2734
 
2701
2735
  // packages/modules/dist/secrets.server.js
2702
- 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";
2703
2737
 
2704
2738
  // packages/proxy/dist/errors.js
2705
2739
  var ProxyError = class extends Error {
@@ -2825,7 +2859,7 @@ import https from "node:https";
2825
2859
  var DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
2826
2860
 
2827
2861
  // packages/proxy/dist/upstreams.server.js
2828
- 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";
2829
2863
 
2830
2864
  // packages/proxy/dist/forward.server.js
2831
2865
  import { promisify } from "node:util";
@@ -2899,11 +2933,11 @@ async function setModuleSecret(input) {
2899
2933
  });
2900
2934
  }
2901
2935
  async function deleteModuleSecret(appId, module, name) {
2902
- 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 });
2903
2937
  return rows.length > 0;
2904
2938
  }
2905
2939
  async function getModuleSecret(appId, module, name, env) {
2906
- 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);
2907
2941
  if (!row)
2908
2942
  return null;
2909
2943
  return decryptSecret({ ciphertext: row.ciphertext, iv: row.iv, authTag: row.authTag, wrappedDek: row.wrappedDek, kekId: row.kekId }, env);
@@ -2911,18 +2945,18 @@ async function getModuleSecret(appId, module, name, env) {
2911
2945
  async function secretsStatus(appId, module, names) {
2912
2946
  if (names.length === 0)
2913
2947
  return /* @__PURE__ */ new Map();
2914
- 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)));
2915
2949
  return new Map(rows.map((r) => [r.name, r.updatedAt]));
2916
2950
  }
2917
2951
  async function secretsSet(appId, module, names) {
2918
2952
  if (names.length === 0)
2919
2953
  return /* @__PURE__ */ new Set();
2920
- 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)));
2921
2955
  return new Set(rows.map((r) => r.name));
2922
2956
  }
2923
2957
 
2924
2958
  // packages/modules/dist/configs.server.js
2925
- 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";
2926
2960
  function asObject2(v) {
2927
2961
  return v && typeof v === "object" && !Array.isArray(v) ? v : {};
2928
2962
  }
@@ -2939,7 +2973,7 @@ function asPending(v) {
2939
2973
  };
2940
2974
  }
2941
2975
  async function readConfigRow(appId, module, executor = getDb()) {
2942
- 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);
2943
2977
  if (!row)
2944
2978
  return { config: {}, pending: null, updatedAt: null };
2945
2979
  return { config: asObject2(row.config), pending: asPending(row.pending), updatedAt: row.updatedAt };
@@ -2948,7 +2982,7 @@ async function readConfigRows(appId, modules) {
2948
2982
  const out = /* @__PURE__ */ new Map();
2949
2983
  if (modules.length === 0)
2950
2984
  return out;
2951
- 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)));
2952
2986
  for (const r of rows) {
2953
2987
  out.set(r.module, { config: asObject2(r.config), pending: asPending(r.pending), updatedAt: r.updatedAt });
2954
2988
  }
@@ -2957,7 +2991,7 @@ async function readConfigRows(appId, modules) {
2957
2991
  async function withLockedConfig(appId, module, fn) {
2958
2992
  return getDb().transaction(async (tx) => {
2959
2993
  await tx.insert(moduleConfigs).values({ appId, module }).onConflictDoNothing();
2960
- 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");
2961
2995
  const current = {
2962
2996
  config: asObject2(row?.config),
2963
2997
  pending: asPending(row?.pending),
@@ -2969,7 +3003,7 @@ async function withLockedConfig(appId, module, fn) {
2969
3003
  set.config = next.config;
2970
3004
  if (next.pending !== void 0)
2971
3005
  set.pending = next.pending;
2972
- 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)));
2973
3007
  };
2974
3008
  return fn(current, write, tx);
2975
3009
  });
@@ -3430,7 +3464,7 @@ function redisMailGuard(opts) {
3430
3464
  const ttl = await r.pttl(key);
3431
3465
  return ttl > 0 ? ttl / 1e3 : fallbackSec;
3432
3466
  };
3433
- const count2 = async (r, key, n) => {
3467
+ const count3 = async (r, key, n) => {
3434
3468
  const c = await r.incrby(key, n);
3435
3469
  if (c === n || await r.pttl(key) < 0)
3436
3470
  await r.pexpire(key, HOUR_MS2);
@@ -3493,7 +3527,7 @@ function redisMailGuard(opts) {
3493
3527
  if (!refusal) {
3494
3528
  const key = mailAppCounterKey(meta.app_id, cls);
3495
3529
  const share = appShareOf(budgets, cls);
3496
- const used = await count2(r, key, recipients);
3530
+ const used = await count3(r, key, recipients);
3497
3531
  if (used > share) {
3498
3532
  if (used - recipients <= share) {
3499
3533
  log.warn(`module e-mail: an app used its hourly share of ${cls === "sign_in" ? "sign-in codes" : "notifications"}`, {
@@ -3509,7 +3543,7 @@ function redisMailGuard(opts) {
3509
3543
  if (!refusal) {
3510
3544
  const key = mailWorkspaceCounterKey(meta.workspace_id, cls);
3511
3545
  const share = workspaceShareOf(budgets, cls);
3512
- const used = await count2(r, key, recipients);
3546
+ const used = await count3(r, key, recipients);
3513
3547
  if (used > share) {
3514
3548
  if (used - recipients <= share) {
3515
3549
  log.warn(`module e-mail: a workspace used its hourly share of ${cls === "sign_in" ? "sign-in codes" : "notifications"}`, {
@@ -3523,7 +3557,7 @@ function redisMailGuard(opts) {
3523
3557
  }
3524
3558
  }
3525
3559
  if (!refusal) {
3526
- const used = await count2(r, MAIL_COUNTER_KEYS[cls], recipients);
3560
+ const used = await count3(r, MAIL_COUNTER_KEYS[cls], recipients);
3527
3561
  if (used > budgets[cls]) {
3528
3562
  await r.set(MAIL_PAUSE_KEYS[cls], "1", "PX", pauseMs);
3529
3563
  await r.set(MAIL_COUNTER_KEYS[cls], "0", "PX", pauseMs);
@@ -4132,43 +4166,43 @@ function readModulesLock(modulesDir) {
4132
4166
  // packages/modules/dist/migration-lint.js
4133
4167
  import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "node:fs";
4134
4168
  import { join as join7 } from "node:path";
4135
- function blank(sql15) {
4136
- const out = sql15.split("");
4169
+ function blank(sql17) {
4170
+ const out = sql17.split("");
4137
4171
  const wipe = (from, to) => {
4138
4172
  for (let k = from; k < to; k++)
4139
4173
  if (out[k] !== "\n")
4140
4174
  out[k] = " ";
4141
4175
  };
4142
4176
  let i = 0;
4143
- while (i < sql15.length) {
4144
- const c = sql15[i];
4145
- if (c === "-" && sql15[i + 1] === "-") {
4146
- const end = sql15.indexOf("\n", i);
4147
- 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;
4148
4182
  wipe(i, stop);
4149
4183
  i = stop;
4150
- } else if (c === "/" && sql15[i + 1] === "*") {
4151
- const end = sql15.indexOf("*/", i + 2);
4152
- 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;
4153
4187
  wipe(i, stop);
4154
4188
  i = stop;
4155
4189
  } else if (c === "'") {
4156
4190
  let j = i + 1;
4157
- while (j < sql15.length) {
4158
- if (sql15[j] === "'" && sql15[j + 1] === "'")
4191
+ while (j < sql17.length) {
4192
+ if (sql17[j] === "'" && sql17[j + 1] === "'")
4159
4193
  j += 2;
4160
- else if (sql15[j] === "'")
4194
+ else if (sql17[j] === "'")
4161
4195
  break;
4162
4196
  else
4163
4197
  j++;
4164
4198
  }
4165
- wipe(i, Math.min(j + 1, sql15.length));
4199
+ wipe(i, Math.min(j + 1, sql17.length));
4166
4200
  i = j + 1;
4167
4201
  } else if (c === '"') {
4168
- const end = sql15.indexOf('"', i + 1);
4169
- i = end === -1 ? sql15.length : end + 1;
4202
+ const end = sql17.indexOf('"', i + 1);
4203
+ i = end === -1 ? sql17.length : end + 1;
4170
4204
  } else if (c === "$") {
4171
- const tag = /^\$[A-Za-z_]*\$/.exec(sql15.slice(i));
4205
+ const tag = /^\$[A-Za-z_]*\$/.exec(sql17.slice(i));
4172
4206
  if (tag) {
4173
4207
  wipe(i, i + tag[0].length);
4174
4208
  i += tag[0].length;
@@ -4188,8 +4222,8 @@ function splitName(raw) {
4188
4222
  const unq = (p) => p.startsWith('"') ? p.slice(1, -1).replace(/""/g, '"') : p.toLowerCase();
4189
4223
  return parts.length === 2 ? { schema: unq(parts[0]), name: unq(parts[1]) } : { schema: null, name: unq(parts[0] ?? "") };
4190
4224
  }
4191
- function lintMigrationSql(name, sql15, file = "migration.sql") {
4192
- const text2 = blank(sql15);
4225
+ function lintMigrationSql(name, sql17, file = "migration.sql") {
4226
+ const text2 = blank(sql17);
4193
4227
  const prefix = `mod_${name}`;
4194
4228
  const own = (raw) => {
4195
4229
  const { schema, name: n } = splitName(raw);
@@ -5008,20 +5042,20 @@ var LOGS_RETENTION_DAYS = 30;
5008
5042
  import { createHash as createHash8 } from "node:crypto";
5009
5043
 
5010
5044
  // packages/insights/dist/resolve.server.js
5011
- 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";
5012
5046
 
5013
5047
  // packages/insights/dist/signals.server.js
5014
- import { sql as sql10 } from "drizzle-orm";
5048
+ import { sql as sql12 } from "drizzle-orm";
5015
5049
  var SIGNAL_TTL_SEC = (LOGS_RETENTION_DAYS + 1) * 24 * 60 * 60;
5016
- function utcDay(d = /* @__PURE__ */ new Date()) {
5050
+ function utcDay2(d = /* @__PURE__ */ new Date()) {
5017
5051
  return d.toISOString().slice(0, 10);
5018
5052
  }
5019
5053
 
5020
5054
  // packages/insights/dist/beacon.server.js
5021
- import { sql as sql11 } from "drizzle-orm";
5055
+ import { sql as sql13 } from "drizzle-orm";
5022
5056
 
5023
5057
  // packages/insights/dist/query.server.js
5024
- 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";
5025
5059
  var DAY_MS = 24 * 60 * 60 * 1e3;
5026
5060
 
5027
5061
  // packages/insights/dist/logs.js
@@ -5037,10 +5071,10 @@ function statusClass(status) {
5037
5071
  }
5038
5072
 
5039
5073
  // packages/insights/dist/logs.server.js
5040
- 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";
5041
5075
 
5042
5076
  // packages/insights/dist/module-stats.server.js
5043
- import { sql as sql12 } from "drizzle-orm";
5077
+ import { sql as sql14 } from "drizzle-orm";
5044
5078
  var MODULE_STATS_FLUSH_SEC = 60;
5045
5079
  var MODULE_STATS_TTL_SEC = (LOGS_RETENTION_DAYS + 1) * 24 * 60 * 60;
5046
5080
  var sharedRedis = () => getRedis();
@@ -5053,7 +5087,7 @@ function flushMarkerKey(appId, day) {
5053
5087
  async function recordModuleRequest(appId, module, status, opts = {}) {
5054
5088
  try {
5055
5089
  const r = (opts.redis ?? sharedRedis)();
5056
- const day = utcDay(opts.now ?? /* @__PURE__ */ new Date());
5090
+ const day = utcDay2(opts.now ?? /* @__PURE__ */ new Date());
5057
5091
  const key = moduleCountersKey(appId, day);
5058
5092
  if (await r.hincrby(key, `${module}:${statusClass(status)}`, 1) === 1)
5059
5093
  await r.expire(key, MODULE_STATS_TTL_SEC);
@@ -5070,10 +5104,10 @@ function moduleStatRows(appId, day, counters) {
5070
5104
  const at = field.lastIndexOf(":");
5071
5105
  const module = field.slice(0, at);
5072
5106
  const cls = field.slice(at + 1);
5073
- const count2 = Number(raw);
5074
- 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)
5075
5109
  continue;
5076
- rows.push({ appId, module, statusClass: cls, day, count: count2 });
5110
+ rows.push({ appId, module, statusClass: cls, day, count: count3 });
5077
5111
  }
5078
5112
  return rows;
5079
5113
  }
@@ -5082,7 +5116,7 @@ async function upsertModuleStats(rows) {
5082
5116
  return;
5083
5117
  await getDb().insert(moduleRequestStats).values(rows).onConflictDoUpdate({
5084
5118
  target: [moduleRequestStats.appId, moduleRequestStats.module, moduleRequestStats.statusClass, moduleRequestStats.day],
5085
- set: { count: sql12`greatest(${moduleRequestStats.count}, excluded.count)` }
5119
+ set: { count: sql14`greatest(${moduleRequestStats.count}, excluded.count)` }
5086
5120
  });
5087
5121
  }
5088
5122
  async function flushModuleRequests(appId, day, opts = {}) {
@@ -5097,12 +5131,12 @@ async function flushModuleRequests(appId, day, opts = {}) {
5097
5131
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
5098
5132
 
5099
5133
  // packages/insights/dist/prune.server.js
5100
- import { lt as lt6, sql as sql14 } from "drizzle-orm";
5134
+ import { lt as lt6, sql as sql16 } from "drizzle-orm";
5101
5135
  var DEFAULT_LOGS_PRUNE_INTERVAL_MS = 60 * 60 * 1e3;
5102
5136
  var DAY_MS3 = 24 * 60 * 60 * 1e3;
5103
5137
 
5104
5138
  // packages/modules/dist/runtime.js
5105
- 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";
5106
5140
  import { z } from "zod";
5107
5141
  function redisRateLimiter(redis) {
5108
5142
  return async (key, max, windowMs) => {
@@ -5149,7 +5183,7 @@ function smtpEmailTransport(log, env = process.env) {
5149
5183
  };
5150
5184
  }
5151
5185
  async function appOwnerEmails(db2, workspaceId) {
5152
- 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"])));
5153
5187
  return rows.map((r) => r.email);
5154
5188
  }
5155
5189
  function uniqueKeyOf(value, unique) {
@@ -5306,7 +5340,7 @@ var ModuleRuntime = class {
5306
5340
  return out;
5307
5341
  const plan = await this.deps.limits.fromPlan?.(workspaceId) ?? null;
5308
5342
  const env = this.deps.limits.defaults();
5309
- 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)))) : [];
5310
5344
  const byModule = new Map(rows.map((r) => [r.module, { enabledBy: r.enabledBy, enabledAt: r.enabledAt }]));
5311
5345
  for (const m of modules) {
5312
5346
  const key = moduleEnabledLimitName(m.name);
@@ -5386,7 +5420,7 @@ var ModuleRuntime = class {
5386
5420
  });
5387
5421
  }
5388
5422
  return this.deps.db().transaction(async (tx) => {
5389
- 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;
5390
5424
  if (changed) {
5391
5425
  await writeAudit({
5392
5426
  workspaceId: input.workspaceId,
@@ -5459,7 +5493,7 @@ var ModuleRuntime = class {
5459
5493
  return null;
5460
5494
  const deps = this.deps;
5461
5495
  const db2 = deps.db();
5462
- 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);
5463
5497
  if (!row || row.deletedAt || row.lockedReason)
5464
5498
  return null;
5465
5499
  const app = { id: row.id, slug: row.slug, workspaceId: row.workspaceId };
@@ -5931,7 +5965,7 @@ var ModuleRuntime = class {
5931
5965
  async workspaceOf(appId) {
5932
5966
  if (this.optInModules().length === 0)
5933
5967
  return "";
5934
- 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);
5935
5969
  return row?.workspaceId ?? "";
5936
5970
  }
5937
5971
  requireModule(name) {
@@ -6062,7 +6096,7 @@ var ModuleRuntime = class {
6062
6096
  const waiting = await this.pendingSummary(app.id);
6063
6097
  if (waiting.length === 0)
6064
6098
  return;
6065
- 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);
6066
6100
  const mail = pendingMail({
6067
6101
  appName: row?.name ?? app.slug,
6068
6102
  modules: waiting.map((w) => ({ ...w, confirmUrl: confirmUrl(deps.env, app.workspaceSlug, app.slug, w.module) }))
@@ -6256,11 +6290,11 @@ var ModuleRuntime = class {
6256
6290
  }
6257
6291
  /** get_logs `requests`: one response of a matched route of an active module (fire-and-forget). */
6258
6292
  countRequest(appId, module, status) {
6259
- const count2 = this.deps.requestStats;
6260
- if (!count2)
6293
+ const count3 = this.deps.requestStats;
6294
+ if (!count3)
6261
6295
  return;
6262
6296
  try {
6263
- void Promise.resolve(count2(appId, module, status)).catch(() => void 0);
6297
+ void Promise.resolve(count3(appId, module, status)).catch(() => void 0);
6264
6298
  } catch {
6265
6299
  }
6266
6300
  }
@@ -6504,6 +6538,8 @@ export {
6504
6538
  decideAccess,
6505
6539
  workspaces,
6506
6540
  apps,
6541
+ moduleConfigs,
6542
+ getDb,
6507
6543
  noopLogger,
6508
6544
  perIpLimitKey,
6509
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-OCSLKNR2.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-OCSLKNR2.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
  {
@@ -404,6 +424,16 @@ var TOOL_DOCS = [
404
424
  ];
405
425
  var TOOL_NAMES = TOOL_DOCS.map((t) => t.name);
406
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
+
407
437
  // packages/agent-dx/dist/errors-catalogue.js
408
438
  var ERROR_CATALOGUE = [
409
439
  // ── MCP tools (isError: true, body { code, message, hint }) ───────────────
@@ -505,10 +535,16 @@ var ERROR_CATALOGUE = [
505
535
  },
506
536
  {
507
537
  code: "gallery_disabled",
508
- surface: "MCP tool isError (set_gallery_listing)",
538
+ surface: "MCP tool isError (set_gallery_listing, duplicate_app)",
509
539
  meaning: "This server runs no public gallery (its operator left GALLERY_ENABLED off). Nothing changed.",
510
540
  fix: "Tell the user this server has no public gallery; do not retry."
511
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
+ },
512
548
  {
513
549
  code: "asset_too_large",
514
550
  surface: "MCP tool isError (create_asset_upload); upload URL 413",
@@ -659,9 +695,9 @@ var ERROR_CATALOGUE = [
659
695
  },
660
696
  {
661
697
  code: "rate_limited",
662
- surface: "module route 429 (DrobekError), Retry-After; MCP tool isError (create_asset_upload)",
663
- 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.",
664
- 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."
665
701
  },
666
702
  {
667
703
  code: "payload_too_large",
@@ -805,6 +841,11 @@ var LIMITS = [
805
841
  default: "60",
806
842
  meaning: "Upload URLs one app may get per hour (create_asset_upload \u2192 rate_limited)."
807
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
+ },
808
849
  {
809
850
  env: "tool: asset upload URL",
810
851
  default: "30 min, single use",
@@ -853,16 +894,6 @@ var DOC_PAGES = {
853
894
  licensing: "https://github.com/freema/drobek/blob/main/docs/LICENSING.md"
854
895
  };
855
896
 
856
- // packages/agent-dx/dist/plugin.js
857
- var PLUGIN_REPO = "freema/drobek-plugin";
858
- var PLUGIN_REPO_URL = `https://github.com/${PLUGIN_REPO}`;
859
- var PLUGIN_MARKETPLACE = "drobek";
860
- var PLUGIN_NAME = "drobek";
861
- var PLUGIN_MARKETPLACE_ADD_COMMAND = `claude plugin marketplace add ${PLUGIN_REPO}`;
862
- var PLUGIN_INSTALL_COMMAND = `claude plugin install ${PLUGIN_NAME}@${PLUGIN_MARKETPLACE}`;
863
- var PLUGIN_BUILD_COMMAND = `/${PLUGIN_NAME}:build-app`;
864
- var PLUGIN_PORT_COMMAND = `/${PLUGIN_NAME}:port-artifact`;
865
-
866
897
  // packages/agent-dx/dist/render.js
867
898
  var AGENT_GUIDE_URL = DOC_PAGES.agent;
868
899
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freema/drobek-modules",
3
- "version": "0.4.0",
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.4.0",
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",