@open-mercato/storage-s3 0.6.2-develop.3467.1.2a1818709d → 0.6.2

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.
@@ -1,2 +1,2 @@
1
- Found 16 entry points
1
+ Found 18 entry points
2
2
  storage-s3 built successfully
@@ -0,0 +1,123 @@
1
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
2
+ import { Organization } from "@open-mercato/core/modules/directory/data/entities";
3
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
+ import {
5
+ mapOrganizationsToScopes,
6
+ parseCliArgs,
7
+ resolveCliMode,
8
+ runConfigureFromEnv,
9
+ runConfigureFromEnvForScopes
10
+ } from "./lib/configure-from-env.js";
11
+ function printHelp() {
12
+ console.log("Usage: yarn mercato storage_s3 configure-from-env [--tenant <tenantId> --org <organizationId> | --all-tenants] [--force]");
13
+ console.log("");
14
+ console.log("Modes:");
15
+ console.log(" --tenant <id> --org <id> Apply the preset to a single tenant + organization pair.");
16
+ console.log(" --all-tenants Apply the preset to every active (tenant, organization) pair.");
17
+ console.log(" Designed for unattended deploy hooks; per-tenant skip/force semantics still apply.");
18
+ console.log("");
19
+ console.log("Required env vars:");
20
+ console.log(" OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID");
21
+ console.log(" OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY");
22
+ console.log(" OM_INTEGRATION_STORAGE_S3_REGION");
23
+ console.log(" OM_INTEGRATION_STORAGE_S3_BUCKET");
24
+ console.log("");
25
+ console.log("Optional env vars:");
26
+ console.log(" OM_INTEGRATION_STORAGE_S3_SESSION_TOKEN");
27
+ console.log(" OM_INTEGRATION_STORAGE_S3_ENDPOINT");
28
+ console.log(" OM_INTEGRATION_STORAGE_S3_FORCE_PATH_STYLE");
29
+ console.log(" OM_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE");
30
+ }
31
+ async function listActiveScopes(em) {
32
+ const organizations = await findWithDecryption(
33
+ em,
34
+ Organization,
35
+ { deletedAt: null, isActive: true },
36
+ { populate: ["tenant"] }
37
+ );
38
+ return mapOrganizationsToScopes(organizations);
39
+ }
40
+ const configureFromEnvCommand = {
41
+ command: "configure-from-env",
42
+ async run(rest) {
43
+ const mode = resolveCliMode(parseCliArgs(rest));
44
+ if (mode.kind === "help") {
45
+ printHelp();
46
+ return;
47
+ }
48
+ if (mode.kind === "conflict") {
49
+ console.error(`[storage_s3] ${mode.message}`);
50
+ throw new Error(`Conflicting CLI arguments: ${mode.message}`);
51
+ }
52
+ const container = await createRequestContainer();
53
+ try {
54
+ const credentialsService = container.resolve("integrationCredentialsService");
55
+ const integrationLogService = container.resolve("integrationLogService");
56
+ if (mode.kind === "all") {
57
+ const em = container.resolve("em");
58
+ const scopes = await listActiveScopes(em);
59
+ if (scopes.length === 0) {
60
+ console.log("[storage_s3] --all-tenants: no active organizations found. Nothing to do.");
61
+ return;
62
+ }
63
+ const summary = await runConfigureFromEnvForScopes(
64
+ { credentialsService, integrationLogService },
65
+ scopes,
66
+ { force: mode.force }
67
+ );
68
+ for (const entry of summary.perScope) {
69
+ const prefix = `[storage_s3] tenant=${entry.scope.tenantId} org=${entry.scope.organizationId}`;
70
+ if (entry.outcome.code === 1) {
71
+ console.error(`${prefix} ERROR: ${entry.outcome.message}`);
72
+ } else if (entry.outcome.status === "skipped") {
73
+ console.log(`${prefix} skipped: ${entry.outcome.message}`);
74
+ } else {
75
+ console.log(`${prefix} configured: ${entry.outcome.message}`);
76
+ }
77
+ }
78
+ console.log(
79
+ `[storage_s3] --all-tenants summary: ${summary.configured} configured, ${summary.skipped} skipped, ${summary.errored} error(s).`
80
+ );
81
+ if (summary.code === 1) {
82
+ throw new Error(
83
+ `--all-tenants completed with ${summary.errored} error(s) across ${scopes.length} scope(s). See per-scope errors above.`
84
+ );
85
+ }
86
+ return;
87
+ }
88
+ const outcome = await runConfigureFromEnv(
89
+ { credentialsService, integrationLogService },
90
+ {
91
+ tenantId: mode.tenantId,
92
+ organizationId: mode.organizationId,
93
+ force: mode.force
94
+ }
95
+ );
96
+ if (outcome.code === 0) {
97
+ if (outcome.status === "skipped") {
98
+ console.log(`[storage_s3] Skipped: ${outcome.message}`);
99
+ } else {
100
+ console.log(`[storage_s3] ${outcome.message}`);
101
+ }
102
+ return;
103
+ }
104
+ throw new Error(outcome.message);
105
+ } finally {
106
+ const disposable = container;
107
+ if (typeof disposable.dispose === "function") {
108
+ await disposable.dispose();
109
+ }
110
+ }
111
+ }
112
+ };
113
+ const helpCommand = {
114
+ command: "help",
115
+ async run() {
116
+ printHelp();
117
+ }
118
+ };
119
+ var cli_default = [configureFromEnvCommand, helpCommand];
120
+ export {
121
+ cli_default as default
122
+ };
123
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/storage_s3/cli.ts"],
4
+ "sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ModuleCli } from '@open-mercato/shared/modules/registry'\nimport type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'\nimport type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n mapOrganizationsToScopes,\n parseCliArgs,\n resolveCliMode,\n runConfigureFromEnv,\n runConfigureFromEnvForScopes,\n} from './lib/configure-from-env'\n\nfunction printHelp(): void {\n console.log('Usage: yarn mercato storage_s3 configure-from-env [--tenant <tenantId> --org <organizationId> | --all-tenants] [--force]')\n console.log('')\n console.log('Modes:')\n console.log(' --tenant <id> --org <id> Apply the preset to a single tenant + organization pair.')\n console.log(' --all-tenants Apply the preset to every active (tenant, organization) pair.')\n console.log(' Designed for unattended deploy hooks; per-tenant skip/force semantics still apply.')\n console.log('')\n console.log('Required env vars:')\n console.log(' OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID')\n console.log(' OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY')\n console.log(' OM_INTEGRATION_STORAGE_S3_REGION')\n console.log(' OM_INTEGRATION_STORAGE_S3_BUCKET')\n console.log('')\n console.log('Optional env vars:')\n console.log(' OM_INTEGRATION_STORAGE_S3_SESSION_TOKEN')\n console.log(' OM_INTEGRATION_STORAGE_S3_ENDPOINT')\n console.log(' OM_INTEGRATION_STORAGE_S3_FORCE_PATH_STYLE')\n console.log(' OM_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE')\n}\n\nasync function listActiveScopes(em: EntityManager): Promise<IntegrationScope[]> {\n const organizations = await findWithDecryption(\n em,\n Organization,\n { deletedAt: null, isActive: true },\n { populate: ['tenant'] },\n )\n return mapOrganizationsToScopes(organizations)\n}\n\nconst configureFromEnvCommand: ModuleCli = {\n command: 'configure-from-env',\n async run(rest) {\n const mode = resolveCliMode(parseCliArgs(rest))\n\n if (mode.kind === 'help') {\n printHelp()\n return\n }\n\n if (mode.kind === 'conflict') {\n console.error(`[storage_s3] ${mode.message}`)\n throw new Error(`Conflicting CLI arguments: ${mode.message}`)\n }\n\n const container = await createRequestContainer()\n try {\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const integrationLogService = container.resolve('integrationLogService') as IntegrationLogService\n\n if (mode.kind === 'all') {\n const em = container.resolve('em') as EntityManager\n const scopes = await listActiveScopes(em)\n\n if (scopes.length === 0) {\n console.log('[storage_s3] --all-tenants: no active organizations found. Nothing to do.')\n return\n }\n\n const summary = await runConfigureFromEnvForScopes(\n { credentialsService, integrationLogService },\n scopes,\n { force: mode.force },\n )\n\n for (const entry of summary.perScope) {\n const prefix = `[storage_s3] tenant=${entry.scope.tenantId} org=${entry.scope.organizationId}`\n if (entry.outcome.code === 1) {\n console.error(`${prefix} ERROR: ${entry.outcome.message}`)\n } else if (entry.outcome.status === 'skipped') {\n console.log(`${prefix} skipped: ${entry.outcome.message}`)\n } else {\n console.log(`${prefix} configured: ${entry.outcome.message}`)\n }\n }\n\n console.log(\n `[storage_s3] --all-tenants summary: ${summary.configured} configured, ` +\n `${summary.skipped} skipped, ${summary.errored} error(s).`,\n )\n\n if (summary.code === 1) {\n throw new Error(\n `--all-tenants completed with ${summary.errored} error(s) across ${scopes.length} scope(s). See per-scope errors above.`,\n )\n }\n return\n }\n\n const outcome = await runConfigureFromEnv(\n { credentialsService, integrationLogService },\n {\n tenantId: mode.tenantId,\n organizationId: mode.organizationId,\n force: mode.force,\n },\n )\n\n if (outcome.code === 0) {\n if (outcome.status === 'skipped') {\n console.log(`[storage_s3] Skipped: ${outcome.message}`)\n } else {\n console.log(`[storage_s3] ${outcome.message}`)\n }\n return\n }\n\n throw new Error(outcome.message)\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n },\n}\n\nconst helpCommand: ModuleCli = {\n command: 'help',\n async run() {\n printHelp()\n },\n}\n\nexport default [configureFromEnvCommand, helpCommand]\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,103 @@
1
+ import { applyS3EnvPreset, readS3EnvPreset } from "./preset.js";
2
+ async function runConfigureFromEnv(deps, options) {
3
+ try {
4
+ const preset = readS3EnvPreset(deps.env ?? process.env);
5
+ if (!preset) {
6
+ return {
7
+ code: 0,
8
+ status: "skipped",
9
+ message: "No S3 env preset was found. Set OM_INTEGRATION_STORAGE_S3_* variables to enable preconfiguration."
10
+ };
11
+ }
12
+ const result = await applyS3EnvPreset({
13
+ credentialsService: deps.credentialsService,
14
+ integrationLogService: deps.integrationLogService,
15
+ scope: { tenantId: options.tenantId, organizationId: options.organizationId },
16
+ force: options.force,
17
+ env: deps.env ?? process.env
18
+ });
19
+ if (result.status === "skipped") {
20
+ return { code: 0, status: "skipped", message: result.reason };
21
+ }
22
+ return { code: 0, status: "configured", message: "S3 credentials were configured from env." };
23
+ } catch (error) {
24
+ const message = error instanceof Error ? error.message : "Unknown S3 preset error";
25
+ return { code: 1, status: "error", message };
26
+ }
27
+ }
28
+ async function runConfigureFromEnvForScopes(deps, scopes, options = {}) {
29
+ const perScope = [];
30
+ let configured = 0;
31
+ let skipped = 0;
32
+ let errored = 0;
33
+ for (const scope of scopes) {
34
+ const outcome = await runConfigureFromEnv(deps, {
35
+ tenantId: scope.tenantId,
36
+ organizationId: scope.organizationId,
37
+ force: options.force
38
+ });
39
+ perScope.push({ scope, outcome });
40
+ if (outcome.code === 1) errored += 1;
41
+ else if (outcome.status === "configured") configured += 1;
42
+ else skipped += 1;
43
+ }
44
+ const code = errored > 0 ? 1 : 0;
45
+ return { code, configured, skipped, errored, perScope };
46
+ }
47
+ function parseCliArgs(args) {
48
+ const result = {};
49
+ for (let i = 0; i < args.length; i++) {
50
+ const arg = args[i];
51
+ if (!arg.startsWith("--")) continue;
52
+ const key = arg.slice(2);
53
+ if (key.includes("=")) {
54
+ const [name, value] = key.split("=");
55
+ result[name] = value;
56
+ continue;
57
+ }
58
+ const next = args[i + 1];
59
+ if (next && !next.startsWith("--")) {
60
+ result[key] = next;
61
+ i += 1;
62
+ continue;
63
+ }
64
+ result[key] = true;
65
+ }
66
+ return result;
67
+ }
68
+ function resolveCliMode(args) {
69
+ const allTenants = args["all-tenants"] === true || args.allTenants === true;
70
+ const tenantId = String(args.tenantId ?? args.tenant ?? "");
71
+ const organizationId = String(args.organizationId ?? args.orgId ?? args.org ?? "");
72
+ const force = args.force === true ? true : void 0;
73
+ if (allTenants && (tenantId || organizationId)) {
74
+ return {
75
+ kind: "conflict",
76
+ message: "--all-tenants cannot be combined with --tenant or --org. Pick one mode."
77
+ };
78
+ }
79
+ if (allTenants) {
80
+ return { kind: "all", force };
81
+ }
82
+ if (!tenantId || !organizationId) {
83
+ return { kind: "help" };
84
+ }
85
+ return { kind: "single", tenantId, organizationId, force };
86
+ }
87
+ function mapOrganizationsToScopes(organizations) {
88
+ const scopes = [];
89
+ for (const organization of organizations) {
90
+ const tenantId = organization.tenant?.id;
91
+ if (!tenantId) continue;
92
+ scopes.push({ tenantId, organizationId: organization.id });
93
+ }
94
+ return scopes;
95
+ }
96
+ export {
97
+ mapOrganizationsToScopes,
98
+ parseCliArgs,
99
+ resolveCliMode,
100
+ runConfigureFromEnv,
101
+ runConfigureFromEnvForScopes
102
+ };
103
+ //# sourceMappingURL=configure-from-env.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/storage_s3/lib/configure-from-env.ts"],
4
+ "sourcesContent": ["import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'\nimport type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { applyS3EnvPreset, readS3EnvPreset } from './preset'\n\nexport type ConfigureFromEnvDeps = {\n credentialsService: CredentialsService\n integrationLogService: IntegrationLogService\n env?: NodeJS.ProcessEnv\n}\n\nexport type ConfigureFromEnvOptions = {\n tenantId: string\n organizationId: string\n force?: boolean\n}\n\nexport type ConfigureFromEnvOutcome =\n | { code: 0; status: 'configured' | 'skipped'; message: string }\n | { code: 1; status: 'error'; message: string }\n\nexport async function runConfigureFromEnv(\n deps: ConfigureFromEnvDeps,\n options: ConfigureFromEnvOptions,\n): Promise<ConfigureFromEnvOutcome> {\n try {\n const preset = readS3EnvPreset(deps.env ?? process.env)\n if (!preset) {\n return {\n code: 0,\n status: 'skipped',\n message: 'No S3 env preset was found. Set OM_INTEGRATION_STORAGE_S3_* variables to enable preconfiguration.',\n }\n }\n\n const result = await applyS3EnvPreset({\n credentialsService: deps.credentialsService,\n integrationLogService: deps.integrationLogService,\n scope: { tenantId: options.tenantId, organizationId: options.organizationId },\n force: options.force,\n env: deps.env ?? process.env,\n })\n\n if (result.status === 'skipped') {\n return { code: 0, status: 'skipped', message: result.reason }\n }\n\n return { code: 0, status: 'configured', message: 'S3 credentials were configured from env.' }\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown S3 preset error'\n return { code: 1, status: 'error', message }\n }\n}\n\nexport type ConfigureFromEnvScopeOutcome = {\n scope: IntegrationScope\n outcome: ConfigureFromEnvOutcome\n}\n\nexport type ConfigureFromEnvAllOutcome = {\n code: 0 | 1\n configured: number\n skipped: number\n errored: number\n perScope: ConfigureFromEnvScopeOutcome[]\n}\n\nexport async function runConfigureFromEnvForScopes(\n deps: ConfigureFromEnvDeps,\n scopes: IntegrationScope[],\n options: { force?: boolean } = {},\n): Promise<ConfigureFromEnvAllOutcome> {\n const perScope: ConfigureFromEnvScopeOutcome[] = []\n let configured = 0\n let skipped = 0\n let errored = 0\n\n for (const scope of scopes) {\n const outcome = await runConfigureFromEnv(deps, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n force: options.force,\n })\n perScope.push({ scope, outcome })\n if (outcome.code === 1) errored += 1\n else if (outcome.status === 'configured') configured += 1\n else skipped += 1\n }\n\n const code: 0 | 1 = errored > 0 ? 1 : 0\n return { code, configured, skipped, errored, perScope }\n}\n\nexport function parseCliArgs(args: string[]): Record<string, string | boolean> {\n const result: Record<string, string | boolean> = {}\n\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]\n if (!arg.startsWith('--')) continue\n\n const key = arg.slice(2)\n if (key.includes('=')) {\n const [name, value] = key.split('=')\n result[name] = value\n continue\n }\n\n const next = args[i + 1]\n if (next && !next.startsWith('--')) {\n result[key] = next\n i += 1\n continue\n }\n\n result[key] = true\n }\n\n return result\n}\n\nexport type ConfigureFromEnvCliMode =\n | { kind: 'help' }\n | { kind: 'conflict'; message: string }\n | { kind: 'all'; force?: boolean }\n | { kind: 'single'; tenantId: string; organizationId: string; force?: boolean }\n\nexport function resolveCliMode(args: Record<string, string | boolean>): ConfigureFromEnvCliMode {\n const allTenants = args['all-tenants'] === true || args.allTenants === true\n const tenantId = String(args.tenantId ?? args.tenant ?? '')\n const organizationId = String(args.organizationId ?? args.orgId ?? args.org ?? '')\n const force = args.force === true ? true : undefined\n\n if (allTenants && (tenantId || organizationId)) {\n return {\n kind: 'conflict',\n message: '--all-tenants cannot be combined with --tenant or --org. Pick one mode.',\n }\n }\n\n if (allTenants) {\n return { kind: 'all', force }\n }\n\n if (!tenantId || !organizationId) {\n return { kind: 'help' }\n }\n\n return { kind: 'single', tenantId, organizationId, force }\n}\n\ntype OrganizationRow = {\n id: string\n tenant?: { id?: string | null } | null\n}\n\nexport function mapOrganizationsToScopes(organizations: OrganizationRow[]): IntegrationScope[] {\n const scopes: IntegrationScope[] = []\n for (const organization of organizations) {\n const tenantId = organization.tenant?.id\n if (!tenantId) continue\n scopes.push({ tenantId, organizationId: organization.id })\n }\n return scopes\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -1,21 +1,30 @@
1
1
  import { createCredentialsService } from "@open-mercato/core/modules/integrations/lib/credentials-service";
2
2
  import { createIntegrationLogService } from "@open-mercato/core/modules/integrations/lib/log-service";
3
3
  import { applyS3EnvPreset } from "./lib/preset.js";
4
+ const S3_INTEGRATION_ID = "storage_s3";
4
5
  const setup = {
5
6
  defaultRoleFeatures: {
6
7
  superadmin: ["storage_providers.manage"],
7
8
  admin: ["storage_providers.manage"]
8
9
  },
9
10
  async onTenantCreated({ em, organizationId, tenantId }) {
11
+ const integrationLogService = createIntegrationLogService(em);
10
12
  try {
11
13
  await applyS3EnvPreset({
12
14
  credentialsService: createCredentialsService(em),
13
- integrationLogService: createIntegrationLogService(em),
15
+ integrationLogService,
14
16
  scope: { tenantId, organizationId }
15
17
  });
16
18
  } catch (error) {
17
19
  const message = error instanceof Error ? error.message : "Unknown S3 preset error";
18
- console.warn(`[storage_s3] Failed to apply env preset during tenant setup: ${message}`);
20
+ try {
21
+ await integrationLogService.scoped(S3_INTEGRATION_ID, { tenantId, organizationId }).error(`Failed to apply S3 env preset during tenant setup: ${message}`);
22
+ } catch (logError) {
23
+ const logMessage = logError instanceof Error ? logError.message : "Unknown integration log error";
24
+ console.error(
25
+ `[storage_s3] Failed to apply env preset during tenant setup: ${message}. Also failed to persist the error to integration logs: ${logMessage}`
26
+ );
27
+ }
19
28
  }
20
29
  }
21
30
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/storage_s3/setup.ts"],
4
- "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { createCredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'\nimport { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport { applyS3EnvPreset } from './lib/preset'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['storage_providers.manage'],\n admin: ['storage_providers.manage'],\n },\n\n async onTenantCreated({ em, organizationId, tenantId }) {\n try {\n await applyS3EnvPreset({\n credentialsService: createCredentialsService(em),\n integrationLogService: createIntegrationLogService(em),\n scope: { tenantId, organizationId },\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown S3 preset error'\n console.warn(`[storage_s3] Failed to apply env preset during tenant setup: ${message}`)\n }\n },\n}\n\nexport default setup\n"],
5
- "mappings": "AACA,SAAS,gCAAgC;AACzC,SAAS,mCAAmC;AAC5C,SAAS,wBAAwB;AAE1B,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,YAAY,CAAC,0BAA0B;AAAA,IACvC,OAAO,CAAC,0BAA0B;AAAA,EACpC;AAAA,EAEA,MAAM,gBAAgB,EAAE,IAAI,gBAAgB,SAAS,GAAG;AACtD,QAAI;AACF,YAAM,iBAAiB;AAAA,QACrB,oBAAoB,yBAAyB,EAAE;AAAA,QAC/C,uBAAuB,4BAA4B,EAAE;AAAA,QACrD,OAAO,EAAE,UAAU,eAAe;AAAA,MACpC,CAAC;AAAA,IACH,SAAS,OAAO;AACd,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,cAAQ,KAAK,gEAAgE,OAAO,EAAE;AAAA,IACxF;AAAA,EACF;AACF;AAEA,IAAO,gBAAQ;",
4
+ "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport { createCredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'\nimport { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'\nimport { applyS3EnvPreset } from './lib/preset'\n\nconst S3_INTEGRATION_ID = 'storage_s3'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['storage_providers.manage'],\n admin: ['storage_providers.manage'],\n },\n\n async onTenantCreated({ em, organizationId, tenantId }) {\n const integrationLogService = createIntegrationLogService(em)\n try {\n await applyS3EnvPreset({\n credentialsService: createCredentialsService(em),\n integrationLogService,\n scope: { tenantId, organizationId },\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown S3 preset error'\n try {\n await integrationLogService\n .scoped(S3_INTEGRATION_ID, { tenantId, organizationId })\n .error(`Failed to apply S3 env preset during tenant setup: ${message}`)\n } catch (logError) {\n const logMessage = logError instanceof Error ? logError.message : 'Unknown integration log error'\n console.error(\n `[storage_s3] Failed to apply env preset during tenant setup: ${message}. ` +\n `Also failed to persist the error to integration logs: ${logMessage}`,\n )\n }\n }\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AACA,SAAS,gCAAgC;AACzC,SAAS,mCAAmC;AAC5C,SAAS,wBAAwB;AAEjC,MAAM,oBAAoB;AAEnB,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,YAAY,CAAC,0BAA0B;AAAA,IACvC,OAAO,CAAC,0BAA0B;AAAA,EACpC;AAAA,EAEA,MAAM,gBAAgB,EAAE,IAAI,gBAAgB,SAAS,GAAG;AACtD,UAAM,wBAAwB,4BAA4B,EAAE;AAC5D,QAAI;AACF,YAAM,iBAAiB;AAAA,QACrB,oBAAoB,yBAAyB,EAAE;AAAA,QAC/C;AAAA,QACA,OAAO,EAAE,UAAU,eAAe;AAAA,MACpC,CAAC;AAAA,IACH,SAAS,OAAO;AACd,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,UAAI;AACF,cAAM,sBACH,OAAO,mBAAmB,EAAE,UAAU,eAAe,CAAC,EACtD,MAAM,sDAAsD,OAAO,EAAE;AAAA,MAC1E,SAAS,UAAU;AACjB,cAAM,aAAa,oBAAoB,QAAQ,SAAS,UAAU;AAClE,gBAAQ;AAAA,UACN,gEAAgE,OAAO,2DACZ,UAAU;AAAA,QACvE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAEA,IAAO,gBAAQ;",
6
6
  "names": []
7
7
  }
package/jest.config.cjs CHANGED
@@ -15,6 +15,8 @@ module.exports = {
15
15
  {
16
16
  tsconfig: {
17
17
  jsx: 'react-jsx',
18
+ rootDir: '.',
19
+ ignoreDeprecations: '6.0',
18
20
  },
19
21
  },
20
22
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/storage-s3",
3
- "version": "0.6.2-develop.3467.1.2a1818709d",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -68,14 +68,14 @@
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-s3": "^3.821.0",
70
70
  "@aws-sdk/s3-request-presigner": "^3.821.0",
71
- "@open-mercato/core": "0.6.2-develop.3467.1.2a1818709d"
71
+ "@open-mercato/core": "0.6.2"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@mikro-orm/postgresql": "^6.6.10",
75
- "@open-mercato/shared": "0.6.2-develop.3467.1.2a1818709d"
75
+ "@open-mercato/shared": "0.6.2"
76
76
  },
77
77
  "devDependencies": {
78
- "@open-mercato/shared": "0.6.2-develop.3467.1.2a1818709d",
78
+ "@open-mercato/shared": "0.6.2",
79
79
  "@types/jest": "^30.0.0",
80
80
  "esbuild": "^0.25.2",
81
81
  "glob": "^11.0.3",
@@ -89,6 +89,5 @@
89
89
  "type": "git",
90
90
  "url": "https://github.com/open-mercato/open-mercato",
91
91
  "directory": "packages/storage-s3"
92
- },
93
- "stableVersion": "0.4.10"
92
+ }
94
93
  }
@@ -0,0 +1,418 @@
1
+ import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
2
+ import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
3
+ import {
4
+ mapOrganizationsToScopes,
5
+ parseCliArgs,
6
+ resolveCliMode,
7
+ runConfigureFromEnv,
8
+ runConfigureFromEnvForScopes,
9
+ } from '../lib/configure-from-env'
10
+
11
+ function buildLogService() {
12
+ const info = jest.fn()
13
+ const integrationLogService = {
14
+ scoped: jest.fn(() => ({ info })),
15
+ } as unknown as IntegrationLogService
16
+ return { integrationLogService, info }
17
+ }
18
+
19
+ describe('storage_s3 configure-from-env CLI handler', () => {
20
+ it('skips when no env vars are set', async () => {
21
+ const credentialsService = {
22
+ getRaw: jest.fn(),
23
+ save: jest.fn(),
24
+ } as unknown as CredentialsService
25
+ const { integrationLogService } = buildLogService()
26
+
27
+ const outcome = await runConfigureFromEnv(
28
+ { credentialsService, integrationLogService, env: {} },
29
+ { tenantId: 't', organizationId: 'o' },
30
+ )
31
+
32
+ expect(outcome).toEqual({
33
+ code: 0,
34
+ status: 'skipped',
35
+ message: expect.stringMatching(/No S3 env preset/),
36
+ })
37
+ expect(credentialsService.save).not.toHaveBeenCalled()
38
+ })
39
+
40
+ it('configures credentials with a complete env preset', async () => {
41
+ const credentialsService = {
42
+ getRaw: jest.fn().mockResolvedValue(null),
43
+ save: jest.fn(),
44
+ } as unknown as CredentialsService
45
+ const { integrationLogService, info } = buildLogService()
46
+
47
+ const outcome = await runConfigureFromEnv(
48
+ {
49
+ credentialsService,
50
+ integrationLogService,
51
+ env: {
52
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
53
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
54
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
55
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
56
+ },
57
+ },
58
+ { tenantId: 't', organizationId: 'o' },
59
+ )
60
+
61
+ expect(outcome).toEqual({
62
+ code: 0,
63
+ status: 'configured',
64
+ message: expect.stringMatching(/configured from env/i),
65
+ })
66
+ expect(credentialsService.save).toHaveBeenCalledTimes(1)
67
+ expect(info).toHaveBeenCalledTimes(1)
68
+ })
69
+
70
+ it('skips when credentials already exist and --force is not set', async () => {
71
+ const credentialsService = {
72
+ getRaw: jest.fn().mockResolvedValue({ accessKeyId: 'existing' }),
73
+ save: jest.fn(),
74
+ } as unknown as CredentialsService
75
+ const { integrationLogService } = buildLogService()
76
+
77
+ const outcome = await runConfigureFromEnv(
78
+ {
79
+ credentialsService,
80
+ integrationLogService,
81
+ env: {
82
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
83
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
84
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
85
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
86
+ },
87
+ },
88
+ { tenantId: 't', organizationId: 'o' },
89
+ )
90
+
91
+ expect(outcome).toEqual({
92
+ code: 0,
93
+ status: 'skipped',
94
+ message: expect.stringMatching(/already exist/i),
95
+ })
96
+ expect(credentialsService.save).not.toHaveBeenCalled()
97
+ })
98
+
99
+ it('overwrites credentials when --force is true', async () => {
100
+ const credentialsService = {
101
+ getRaw: jest.fn().mockResolvedValue({ accessKeyId: 'existing' }),
102
+ save: jest.fn(),
103
+ } as unknown as CredentialsService
104
+ const { integrationLogService } = buildLogService()
105
+
106
+ const outcome = await runConfigureFromEnv(
107
+ {
108
+ credentialsService,
109
+ integrationLogService,
110
+ env: {
111
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
112
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
113
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
114
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
115
+ },
116
+ },
117
+ { tenantId: 't', organizationId: 'o', force: true },
118
+ )
119
+
120
+ expect(outcome.code).toBe(0)
121
+ expect(outcome.status).toBe('configured')
122
+ expect(credentialsService.save).toHaveBeenCalledTimes(1)
123
+ })
124
+
125
+ it('returns code 1 with a clear message for incomplete env presets', async () => {
126
+ const credentialsService = {
127
+ getRaw: jest.fn(),
128
+ save: jest.fn(),
129
+ } as unknown as CredentialsService
130
+ const { integrationLogService } = buildLogService()
131
+
132
+ const outcome = await runConfigureFromEnv(
133
+ {
134
+ credentialsService,
135
+ integrationLogService,
136
+ env: {
137
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
138
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
139
+ },
140
+ },
141
+ { tenantId: 't', organizationId: 'o' },
142
+ )
143
+
144
+ expect(outcome.code).toBe(1)
145
+ expect(outcome.status).toBe('error')
146
+ expect(outcome.message).toMatch(/Incomplete S3 env preset/)
147
+ expect(credentialsService.save).not.toHaveBeenCalled()
148
+ })
149
+ })
150
+
151
+ describe('storage_s3 configure-from-env --all-tenants helper', () => {
152
+ function buildFullEnv() {
153
+ return {
154
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
155
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
156
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
157
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
158
+ }
159
+ }
160
+
161
+ it('iterates every scope and reports per-scope outcomes', async () => {
162
+ const existing = new Set(['org-existing'])
163
+ const saved: Array<{ tenantId: string; organizationId: string }> = []
164
+
165
+ const credentialsService = {
166
+ getRaw: jest.fn(async (_id: string, scope: { tenantId: string; organizationId: string }) =>
167
+ existing.has(scope.organizationId) ? { accessKeyId: 'existing' } : null,
168
+ ),
169
+ save: jest.fn(async (_id: string, _creds: unknown, scope: { tenantId: string; organizationId: string }) => {
170
+ saved.push(scope)
171
+ }),
172
+ } as unknown as CredentialsService
173
+
174
+ const { integrationLogService } = buildLogService()
175
+
176
+ const summary = await runConfigureFromEnvForScopes(
177
+ { credentialsService, integrationLogService, env: buildFullEnv() },
178
+ [
179
+ { tenantId: 't1', organizationId: 'org-fresh' },
180
+ { tenantId: 't2', organizationId: 'org-existing' },
181
+ ],
182
+ )
183
+
184
+ expect(summary).toMatchObject({ code: 0, configured: 1, skipped: 1, errored: 0 })
185
+ expect(summary.perScope).toHaveLength(2)
186
+ expect(summary.perScope[0].outcome.status).toBe('configured')
187
+ expect(summary.perScope[1].outcome.status).toBe('skipped')
188
+ expect(saved).toEqual([{ tenantId: 't1', organizationId: 'org-fresh' }])
189
+ })
190
+
191
+ it('returns exit code 1 when at least one scope errors', async () => {
192
+ const credentialsService = {
193
+ getRaw: jest.fn().mockResolvedValue(null),
194
+ save: jest.fn(async (_id, _creds, scope) => {
195
+ if ((scope as { organizationId: string }).organizationId === 'broken') {
196
+ throw new Error('boom')
197
+ }
198
+ }),
199
+ } as unknown as CredentialsService
200
+
201
+ const { integrationLogService } = buildLogService()
202
+
203
+ const summary = await runConfigureFromEnvForScopes(
204
+ { credentialsService, integrationLogService, env: buildFullEnv() },
205
+ [
206
+ { tenantId: 't1', organizationId: 'ok' },
207
+ { tenantId: 't2', organizationId: 'broken' },
208
+ ],
209
+ )
210
+
211
+ expect(summary.code).toBe(1)
212
+ expect(summary.configured).toBe(1)
213
+ expect(summary.errored).toBe(1)
214
+ expect(summary.perScope[1].outcome.status).toBe('error')
215
+ expect((summary.perScope[1].outcome as { message: string }).message).toMatch(/boom/)
216
+ })
217
+
218
+ it('returns exit code 0 with skip-only outcomes when env is unset', async () => {
219
+ const credentialsService = {
220
+ getRaw: jest.fn(),
221
+ save: jest.fn(),
222
+ } as unknown as CredentialsService
223
+
224
+ const { integrationLogService } = buildLogService()
225
+
226
+ const summary = await runConfigureFromEnvForScopes(
227
+ { credentialsService, integrationLogService, env: {} },
228
+ [
229
+ { tenantId: 't1', organizationId: 'a' },
230
+ { tenantId: 't2', organizationId: 'b' },
231
+ ],
232
+ )
233
+
234
+ expect(summary.code).toBe(0)
235
+ expect(summary.skipped).toBe(2)
236
+ expect(summary.configured).toBe(0)
237
+ expect(summary.errored).toBe(0)
238
+ expect(credentialsService.save).not.toHaveBeenCalled()
239
+ })
240
+
241
+ it('propagates --force to every scope', async () => {
242
+ const saveCalls: Array<{ scope: { tenantId: string; organizationId: string } }> = []
243
+ const credentialsService = {
244
+ getRaw: jest.fn().mockResolvedValue({ accessKeyId: 'existing' }),
245
+ save: jest.fn(async (_id, _creds, scope) => {
246
+ saveCalls.push({ scope: scope as { tenantId: string; organizationId: string } })
247
+ }),
248
+ } as unknown as CredentialsService
249
+
250
+ const { integrationLogService } = buildLogService()
251
+
252
+ const summary = await runConfigureFromEnvForScopes(
253
+ { credentialsService, integrationLogService, env: buildFullEnv() },
254
+ [
255
+ { tenantId: 't1', organizationId: 'a' },
256
+ { tenantId: 't2', organizationId: 'b' },
257
+ ],
258
+ { force: true },
259
+ )
260
+
261
+ expect(summary.code).toBe(0)
262
+ expect(summary.configured).toBe(2)
263
+ expect(saveCalls).toHaveLength(2)
264
+ })
265
+ })
266
+
267
+ describe('storage_s3 parseCliArgs', () => {
268
+ it('parses standalone flags as boolean true', () => {
269
+ expect(parseCliArgs(['--all-tenants'])).toEqual({ 'all-tenants': true })
270
+ expect(parseCliArgs(['--force'])).toEqual({ force: true })
271
+ })
272
+
273
+ it('parses --key value pairs', () => {
274
+ expect(parseCliArgs(['--tenant', 'abc', '--org', 'def'])).toEqual({
275
+ tenant: 'abc',
276
+ org: 'def',
277
+ })
278
+ })
279
+
280
+ it('parses --key=value form', () => {
281
+ expect(parseCliArgs(['--tenant=abc', '--org=def'])).toEqual({
282
+ tenant: 'abc',
283
+ org: 'def',
284
+ })
285
+ })
286
+
287
+ it('treats the next --flag as a separate flag, not a value', () => {
288
+ expect(parseCliArgs(['--all-tenants', '--force'])).toEqual({
289
+ 'all-tenants': true,
290
+ force: true,
291
+ })
292
+ })
293
+
294
+ it('ignores positional arguments without a leading --', () => {
295
+ expect(parseCliArgs(['ignored', '--tenant', 'abc', 'also-ignored'])).toEqual({
296
+ tenant: 'abc',
297
+ })
298
+ })
299
+ })
300
+
301
+ describe('storage_s3 resolveCliMode', () => {
302
+ it('returns help when no tenant/org and no --all-tenants is provided', () => {
303
+ expect(resolveCliMode({})).toEqual({ kind: 'help' })
304
+ expect(resolveCliMode({ force: true })).toEqual({ kind: 'help' })
305
+ })
306
+
307
+ it('returns help when only --tenant is provided without --org', () => {
308
+ expect(resolveCliMode({ tenant: 'abc' })).toEqual({ kind: 'help' })
309
+ })
310
+
311
+ it('returns help when only --org is provided without --tenant', () => {
312
+ expect(resolveCliMode({ org: 'def' })).toEqual({ kind: 'help' })
313
+ })
314
+
315
+ it('returns single mode when both --tenant and --org are provided', () => {
316
+ expect(resolveCliMode({ tenant: 'abc', org: 'def' })).toEqual({
317
+ kind: 'single',
318
+ tenantId: 'abc',
319
+ organizationId: 'def',
320
+ force: undefined,
321
+ })
322
+ })
323
+
324
+ it('accepts the alias forms --tenantId / --organizationId / --orgId', () => {
325
+ expect(resolveCliMode({ tenantId: 'abc', organizationId: 'def' })).toEqual({
326
+ kind: 'single',
327
+ tenantId: 'abc',
328
+ organizationId: 'def',
329
+ force: undefined,
330
+ })
331
+ expect(resolveCliMode({ tenant: 'abc', orgId: 'def' })).toEqual({
332
+ kind: 'single',
333
+ tenantId: 'abc',
334
+ organizationId: 'def',
335
+ force: undefined,
336
+ })
337
+ })
338
+
339
+ it('propagates --force to single mode', () => {
340
+ expect(resolveCliMode({ tenant: 'abc', org: 'def', force: true })).toEqual({
341
+ kind: 'single',
342
+ tenantId: 'abc',
343
+ organizationId: 'def',
344
+ force: true,
345
+ })
346
+ })
347
+
348
+ it('returns all mode when --all-tenants is provided alone', () => {
349
+ expect(resolveCliMode({ 'all-tenants': true })).toEqual({
350
+ kind: 'all',
351
+ force: undefined,
352
+ })
353
+ })
354
+
355
+ it('accepts --allTenants as a camelCase alias for --all-tenants', () => {
356
+ expect(resolveCliMode({ allTenants: true })).toEqual({
357
+ kind: 'all',
358
+ force: undefined,
359
+ })
360
+ })
361
+
362
+ it('propagates --force to all mode', () => {
363
+ expect(resolveCliMode({ 'all-tenants': true, force: true })).toEqual({
364
+ kind: 'all',
365
+ force: true,
366
+ })
367
+ })
368
+
369
+ it('returns conflict when --all-tenants is combined with --tenant', () => {
370
+ const mode = resolveCliMode({ 'all-tenants': true, tenant: 'abc' })
371
+ expect(mode.kind).toBe('conflict')
372
+ if (mode.kind === 'conflict') {
373
+ expect(mode.message).toMatch(/cannot be combined/i)
374
+ }
375
+ })
376
+
377
+ it('returns conflict when --all-tenants is combined with --org', () => {
378
+ const mode = resolveCliMode({ 'all-tenants': true, org: 'def' })
379
+ expect(mode.kind).toBe('conflict')
380
+ })
381
+
382
+ it('returns conflict when --all-tenants is combined with both --tenant and --org', () => {
383
+ expect(resolveCliMode({ 'all-tenants': true, tenant: 'abc', org: 'def' }).kind).toBe('conflict')
384
+ })
385
+ })
386
+
387
+ describe('storage_s3 mapOrganizationsToScopes', () => {
388
+ it('returns an empty array when there are no organizations', () => {
389
+ expect(mapOrganizationsToScopes([])).toEqual([])
390
+ })
391
+
392
+ it('returns a scope per organization with its tenant id', () => {
393
+ const orgs = [
394
+ { id: 'org-1', tenant: { id: 'tenant-1' } },
395
+ { id: 'org-2', tenant: { id: 'tenant-1' } },
396
+ { id: 'org-3', tenant: { id: 'tenant-2' } },
397
+ ]
398
+ expect(mapOrganizationsToScopes(orgs)).toEqual([
399
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
400
+ { tenantId: 'tenant-1', organizationId: 'org-2' },
401
+ { tenantId: 'tenant-2', organizationId: 'org-3' },
402
+ ])
403
+ })
404
+
405
+ it('skips organizations without a populated tenant (defensive guard)', () => {
406
+ const orgs = [
407
+ { id: 'org-1', tenant: { id: 'tenant-1' } },
408
+ { id: 'org-2', tenant: null },
409
+ { id: 'org-3' },
410
+ { id: 'org-4', tenant: { id: null } },
411
+ { id: 'org-5', tenant: { id: 'tenant-2' } },
412
+ ]
413
+ expect(mapOrganizationsToScopes(orgs)).toEqual([
414
+ { tenantId: 'tenant-1', organizationId: 'org-1' },
415
+ { tenantId: 'tenant-2', organizationId: 'org-5' },
416
+ ])
417
+ })
418
+ })
@@ -0,0 +1,175 @@
1
+ import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
2
+ import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
3
+ import { applyS3EnvPreset, readS3EnvPreset } from '../lib/preset'
4
+
5
+ describe('storage_s3 preset', () => {
6
+ it('returns null when no env vars are provided', () => {
7
+ expect(readS3EnvPreset({})).toBeNull()
8
+ })
9
+
10
+ it('reads required credentials from env and applies optional fields', () => {
11
+ const preset = readS3EnvPreset({
12
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
13
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
14
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
15
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
16
+ OM_INTEGRATION_STORAGE_S3_SESSION_TOKEN: 'session',
17
+ OM_INTEGRATION_STORAGE_S3_ENDPOINT: 'https://example.com',
18
+ OM_INTEGRATION_STORAGE_S3_FORCE_PATH_STYLE: 'true',
19
+ OM_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE: 'true',
20
+ })
21
+
22
+ expect(preset).not.toBeNull()
23
+ expect(preset?.credentials).toEqual({
24
+ authMode: 'access_keys',
25
+ accessKeyId: 'AKIAEXAMPLE',
26
+ secretAccessKey: 'secret',
27
+ sessionToken: 'session',
28
+ region: 'eu-central-1',
29
+ bucket: 'om-bucket',
30
+ endpoint: 'https://example.com',
31
+ forcePathStyle: true,
32
+ })
33
+ expect(preset?.force).toBe(true)
34
+ })
35
+
36
+ it('throws when any required env value is missing', () => {
37
+ expect(() =>
38
+ readS3EnvPreset({
39
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
40
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
41
+ }),
42
+ ).toThrow(/Incomplete S3 env preset/)
43
+ })
44
+
45
+ it('saves credentials and writes an info log when applied for the first time', async () => {
46
+ const saved: Array<Record<string, unknown>> = []
47
+ const logCalls: Array<{ message: string; payload?: Record<string, unknown> }> = []
48
+
49
+ const credentialsService = {
50
+ getRaw: jest.fn().mockResolvedValue(null),
51
+ save: jest.fn(async (_integrationId, credentials) => {
52
+ saved.push(credentials as Record<string, unknown>)
53
+ }),
54
+ } as unknown as CredentialsService
55
+
56
+ const integrationLogService = {
57
+ scoped: jest.fn(() => ({
58
+ info: async (message: string, payload?: Record<string, unknown>) => {
59
+ logCalls.push({ message, payload })
60
+ },
61
+ })),
62
+ } as unknown as IntegrationLogService
63
+
64
+ const result = await applyS3EnvPreset({
65
+ credentialsService,
66
+ integrationLogService,
67
+ scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
68
+ env: {
69
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
70
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
71
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
72
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
73
+ },
74
+ })
75
+
76
+ expect(result).toEqual({ status: 'configured' })
77
+ expect(saved).toEqual([
78
+ {
79
+ authMode: 'access_keys',
80
+ accessKeyId: 'AKIAEXAMPLE',
81
+ secretAccessKey: 'secret',
82
+ region: 'eu-central-1',
83
+ bucket: 'om-bucket',
84
+ },
85
+ ])
86
+ expect(logCalls).toHaveLength(1)
87
+ expect(logCalls[0].payload).toMatchObject({
88
+ region: 'eu-central-1',
89
+ bucket: 'om-bucket',
90
+ endpoint: null,
91
+ })
92
+ })
93
+
94
+ it('skips when credentials already exist and force is not set', async () => {
95
+ const credentialsService = {
96
+ getRaw: jest.fn().mockResolvedValue({ accessKeyId: 'existing' }),
97
+ save: jest.fn(),
98
+ } as unknown as CredentialsService
99
+
100
+ const integrationLogService = {
101
+ scoped: jest.fn(() => ({ info: jest.fn() })),
102
+ } as unknown as IntegrationLogService
103
+
104
+ const result = await applyS3EnvPreset({
105
+ credentialsService,
106
+ integrationLogService,
107
+ scope: { tenantId: 't', organizationId: 'o' },
108
+ env: {
109
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
110
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
111
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
112
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
113
+ },
114
+ })
115
+
116
+ expect(result.status).toBe('skipped')
117
+ expect(credentialsService.save).not.toHaveBeenCalled()
118
+ })
119
+
120
+ it('overwrites existing credentials when force is true', async () => {
121
+ const saveSpy = jest.fn()
122
+ const credentialsService = {
123
+ getRaw: jest.fn().mockResolvedValue({ accessKeyId: 'existing' }),
124
+ save: saveSpy,
125
+ } as unknown as CredentialsService
126
+
127
+ const integrationLogService = {
128
+ scoped: jest.fn(() => ({ info: jest.fn() })),
129
+ } as unknown as IntegrationLogService
130
+
131
+ const result = await applyS3EnvPreset({
132
+ credentialsService,
133
+ integrationLogService,
134
+ scope: { tenantId: 't', organizationId: 'o' },
135
+ force: true,
136
+ env: {
137
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
138
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
139
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
140
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
141
+ },
142
+ })
143
+
144
+ expect(result.status).toBe('configured')
145
+ expect(saveSpy).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it('honors OM_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE when no explicit force is passed', async () => {
149
+ const saveSpy = jest.fn()
150
+ const credentialsService = {
151
+ getRaw: jest.fn().mockResolvedValue({ accessKeyId: 'existing' }),
152
+ save: saveSpy,
153
+ } as unknown as CredentialsService
154
+
155
+ const integrationLogService = {
156
+ scoped: jest.fn(() => ({ info: jest.fn() })),
157
+ } as unknown as IntegrationLogService
158
+
159
+ const result = await applyS3EnvPreset({
160
+ credentialsService,
161
+ integrationLogService,
162
+ scope: { tenantId: 't', organizationId: 'o' },
163
+ env: {
164
+ OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID: 'AKIAEXAMPLE',
165
+ OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
166
+ OM_INTEGRATION_STORAGE_S3_REGION: 'eu-central-1',
167
+ OM_INTEGRATION_STORAGE_S3_BUCKET: 'om-bucket',
168
+ OM_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE: 'true',
169
+ },
170
+ })
171
+
172
+ expect(result.status).toBe('configured')
173
+ expect(saveSpy).toHaveBeenCalledTimes(1)
174
+ })
175
+ })
@@ -0,0 +1,142 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
4
+ import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
5
+ import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
6
+ import { Organization } from '@open-mercato/core/modules/directory/data/entities'
7
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
8
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
+ import {
10
+ mapOrganizationsToScopes,
11
+ parseCliArgs,
12
+ resolveCliMode,
13
+ runConfigureFromEnv,
14
+ runConfigureFromEnvForScopes,
15
+ } from './lib/configure-from-env'
16
+
17
+ function printHelp(): void {
18
+ console.log('Usage: yarn mercato storage_s3 configure-from-env [--tenant <tenantId> --org <organizationId> | --all-tenants] [--force]')
19
+ console.log('')
20
+ console.log('Modes:')
21
+ console.log(' --tenant <id> --org <id> Apply the preset to a single tenant + organization pair.')
22
+ console.log(' --all-tenants Apply the preset to every active (tenant, organization) pair.')
23
+ console.log(' Designed for unattended deploy hooks; per-tenant skip/force semantics still apply.')
24
+ console.log('')
25
+ console.log('Required env vars:')
26
+ console.log(' OM_INTEGRATION_STORAGE_S3_ACCESS_KEY_ID')
27
+ console.log(' OM_INTEGRATION_STORAGE_S3_SECRET_ACCESS_KEY')
28
+ console.log(' OM_INTEGRATION_STORAGE_S3_REGION')
29
+ console.log(' OM_INTEGRATION_STORAGE_S3_BUCKET')
30
+ console.log('')
31
+ console.log('Optional env vars:')
32
+ console.log(' OM_INTEGRATION_STORAGE_S3_SESSION_TOKEN')
33
+ console.log(' OM_INTEGRATION_STORAGE_S3_ENDPOINT')
34
+ console.log(' OM_INTEGRATION_STORAGE_S3_FORCE_PATH_STYLE')
35
+ console.log(' OM_INTEGRATION_STORAGE_S3_FORCE_PRECONFIGURE')
36
+ }
37
+
38
+ async function listActiveScopes(em: EntityManager): Promise<IntegrationScope[]> {
39
+ const organizations = await findWithDecryption(
40
+ em,
41
+ Organization,
42
+ { deletedAt: null, isActive: true },
43
+ { populate: ['tenant'] },
44
+ )
45
+ return mapOrganizationsToScopes(organizations)
46
+ }
47
+
48
+ const configureFromEnvCommand: ModuleCli = {
49
+ command: 'configure-from-env',
50
+ async run(rest) {
51
+ const mode = resolveCliMode(parseCliArgs(rest))
52
+
53
+ if (mode.kind === 'help') {
54
+ printHelp()
55
+ return
56
+ }
57
+
58
+ if (mode.kind === 'conflict') {
59
+ console.error(`[storage_s3] ${mode.message}`)
60
+ throw new Error(`Conflicting CLI arguments: ${mode.message}`)
61
+ }
62
+
63
+ const container = await createRequestContainer()
64
+ try {
65
+ const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService
66
+ const integrationLogService = container.resolve('integrationLogService') as IntegrationLogService
67
+
68
+ if (mode.kind === 'all') {
69
+ const em = container.resolve('em') as EntityManager
70
+ const scopes = await listActiveScopes(em)
71
+
72
+ if (scopes.length === 0) {
73
+ console.log('[storage_s3] --all-tenants: no active organizations found. Nothing to do.')
74
+ return
75
+ }
76
+
77
+ const summary = await runConfigureFromEnvForScopes(
78
+ { credentialsService, integrationLogService },
79
+ scopes,
80
+ { force: mode.force },
81
+ )
82
+
83
+ for (const entry of summary.perScope) {
84
+ const prefix = `[storage_s3] tenant=${entry.scope.tenantId} org=${entry.scope.organizationId}`
85
+ if (entry.outcome.code === 1) {
86
+ console.error(`${prefix} ERROR: ${entry.outcome.message}`)
87
+ } else if (entry.outcome.status === 'skipped') {
88
+ console.log(`${prefix} skipped: ${entry.outcome.message}`)
89
+ } else {
90
+ console.log(`${prefix} configured: ${entry.outcome.message}`)
91
+ }
92
+ }
93
+
94
+ console.log(
95
+ `[storage_s3] --all-tenants summary: ${summary.configured} configured, ` +
96
+ `${summary.skipped} skipped, ${summary.errored} error(s).`,
97
+ )
98
+
99
+ if (summary.code === 1) {
100
+ throw new Error(
101
+ `--all-tenants completed with ${summary.errored} error(s) across ${scopes.length} scope(s). See per-scope errors above.`,
102
+ )
103
+ }
104
+ return
105
+ }
106
+
107
+ const outcome = await runConfigureFromEnv(
108
+ { credentialsService, integrationLogService },
109
+ {
110
+ tenantId: mode.tenantId,
111
+ organizationId: mode.organizationId,
112
+ force: mode.force,
113
+ },
114
+ )
115
+
116
+ if (outcome.code === 0) {
117
+ if (outcome.status === 'skipped') {
118
+ console.log(`[storage_s3] Skipped: ${outcome.message}`)
119
+ } else {
120
+ console.log(`[storage_s3] ${outcome.message}`)
121
+ }
122
+ return
123
+ }
124
+
125
+ throw new Error(outcome.message)
126
+ } finally {
127
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
128
+ if (typeof disposable.dispose === 'function') {
129
+ await disposable.dispose()
130
+ }
131
+ }
132
+ },
133
+ }
134
+
135
+ const helpCommand: ModuleCli = {
136
+ command: 'help',
137
+ async run() {
138
+ printHelp()
139
+ },
140
+ }
141
+
142
+ export default [configureFromEnvCommand, helpCommand]
@@ -0,0 +1,164 @@
1
+ import type { CredentialsService } from '@open-mercato/core/modules/integrations/lib/credentials-service'
2
+ import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
3
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
4
+ import { applyS3EnvPreset, readS3EnvPreset } from './preset'
5
+
6
+ export type ConfigureFromEnvDeps = {
7
+ credentialsService: CredentialsService
8
+ integrationLogService: IntegrationLogService
9
+ env?: NodeJS.ProcessEnv
10
+ }
11
+
12
+ export type ConfigureFromEnvOptions = {
13
+ tenantId: string
14
+ organizationId: string
15
+ force?: boolean
16
+ }
17
+
18
+ export type ConfigureFromEnvOutcome =
19
+ | { code: 0; status: 'configured' | 'skipped'; message: string }
20
+ | { code: 1; status: 'error'; message: string }
21
+
22
+ export async function runConfigureFromEnv(
23
+ deps: ConfigureFromEnvDeps,
24
+ options: ConfigureFromEnvOptions,
25
+ ): Promise<ConfigureFromEnvOutcome> {
26
+ try {
27
+ const preset = readS3EnvPreset(deps.env ?? process.env)
28
+ if (!preset) {
29
+ return {
30
+ code: 0,
31
+ status: 'skipped',
32
+ message: 'No S3 env preset was found. Set OM_INTEGRATION_STORAGE_S3_* variables to enable preconfiguration.',
33
+ }
34
+ }
35
+
36
+ const result = await applyS3EnvPreset({
37
+ credentialsService: deps.credentialsService,
38
+ integrationLogService: deps.integrationLogService,
39
+ scope: { tenantId: options.tenantId, organizationId: options.organizationId },
40
+ force: options.force,
41
+ env: deps.env ?? process.env,
42
+ })
43
+
44
+ if (result.status === 'skipped') {
45
+ return { code: 0, status: 'skipped', message: result.reason }
46
+ }
47
+
48
+ return { code: 0, status: 'configured', message: 'S3 credentials were configured from env.' }
49
+ } catch (error) {
50
+ const message = error instanceof Error ? error.message : 'Unknown S3 preset error'
51
+ return { code: 1, status: 'error', message }
52
+ }
53
+ }
54
+
55
+ export type ConfigureFromEnvScopeOutcome = {
56
+ scope: IntegrationScope
57
+ outcome: ConfigureFromEnvOutcome
58
+ }
59
+
60
+ export type ConfigureFromEnvAllOutcome = {
61
+ code: 0 | 1
62
+ configured: number
63
+ skipped: number
64
+ errored: number
65
+ perScope: ConfigureFromEnvScopeOutcome[]
66
+ }
67
+
68
+ export async function runConfigureFromEnvForScopes(
69
+ deps: ConfigureFromEnvDeps,
70
+ scopes: IntegrationScope[],
71
+ options: { force?: boolean } = {},
72
+ ): Promise<ConfigureFromEnvAllOutcome> {
73
+ const perScope: ConfigureFromEnvScopeOutcome[] = []
74
+ let configured = 0
75
+ let skipped = 0
76
+ let errored = 0
77
+
78
+ for (const scope of scopes) {
79
+ const outcome = await runConfigureFromEnv(deps, {
80
+ tenantId: scope.tenantId,
81
+ organizationId: scope.organizationId,
82
+ force: options.force,
83
+ })
84
+ perScope.push({ scope, outcome })
85
+ if (outcome.code === 1) errored += 1
86
+ else if (outcome.status === 'configured') configured += 1
87
+ else skipped += 1
88
+ }
89
+
90
+ const code: 0 | 1 = errored > 0 ? 1 : 0
91
+ return { code, configured, skipped, errored, perScope }
92
+ }
93
+
94
+ export function parseCliArgs(args: string[]): Record<string, string | boolean> {
95
+ const result: Record<string, string | boolean> = {}
96
+
97
+ for (let i = 0; i < args.length; i++) {
98
+ const arg = args[i]
99
+ if (!arg.startsWith('--')) continue
100
+
101
+ const key = arg.slice(2)
102
+ if (key.includes('=')) {
103
+ const [name, value] = key.split('=')
104
+ result[name] = value
105
+ continue
106
+ }
107
+
108
+ const next = args[i + 1]
109
+ if (next && !next.startsWith('--')) {
110
+ result[key] = next
111
+ i += 1
112
+ continue
113
+ }
114
+
115
+ result[key] = true
116
+ }
117
+
118
+ return result
119
+ }
120
+
121
+ export type ConfigureFromEnvCliMode =
122
+ | { kind: 'help' }
123
+ | { kind: 'conflict'; message: string }
124
+ | { kind: 'all'; force?: boolean }
125
+ | { kind: 'single'; tenantId: string; organizationId: string; force?: boolean }
126
+
127
+ export function resolveCliMode(args: Record<string, string | boolean>): ConfigureFromEnvCliMode {
128
+ const allTenants = args['all-tenants'] === true || args.allTenants === true
129
+ const tenantId = String(args.tenantId ?? args.tenant ?? '')
130
+ const organizationId = String(args.organizationId ?? args.orgId ?? args.org ?? '')
131
+ const force = args.force === true ? true : undefined
132
+
133
+ if (allTenants && (tenantId || organizationId)) {
134
+ return {
135
+ kind: 'conflict',
136
+ message: '--all-tenants cannot be combined with --tenant or --org. Pick one mode.',
137
+ }
138
+ }
139
+
140
+ if (allTenants) {
141
+ return { kind: 'all', force }
142
+ }
143
+
144
+ if (!tenantId || !organizationId) {
145
+ return { kind: 'help' }
146
+ }
147
+
148
+ return { kind: 'single', tenantId, organizationId, force }
149
+ }
150
+
151
+ type OrganizationRow = {
152
+ id: string
153
+ tenant?: { id?: string | null } | null
154
+ }
155
+
156
+ export function mapOrganizationsToScopes(organizations: OrganizationRow[]): IntegrationScope[] {
157
+ const scopes: IntegrationScope[] = []
158
+ for (const organization of organizations) {
159
+ const tenantId = organization.tenant?.id
160
+ if (!tenantId) continue
161
+ scopes.push({ tenantId, organizationId: organization.id })
162
+ }
163
+ return scopes
164
+ }
@@ -3,6 +3,8 @@ import { createCredentialsService } from '@open-mercato/core/modules/integration
3
3
  import { createIntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
4
4
  import { applyS3EnvPreset } from './lib/preset'
5
5
 
6
+ const S3_INTEGRATION_ID = 'storage_s3'
7
+
6
8
  export const setup: ModuleSetupConfig = {
7
9
  defaultRoleFeatures: {
8
10
  superadmin: ['storage_providers.manage'],
@@ -10,15 +12,26 @@ export const setup: ModuleSetupConfig = {
10
12
  },
11
13
 
12
14
  async onTenantCreated({ em, organizationId, tenantId }) {
15
+ const integrationLogService = createIntegrationLogService(em)
13
16
  try {
14
17
  await applyS3EnvPreset({
15
18
  credentialsService: createCredentialsService(em),
16
- integrationLogService: createIntegrationLogService(em),
19
+ integrationLogService,
17
20
  scope: { tenantId, organizationId },
18
21
  })
19
22
  } catch (error) {
20
23
  const message = error instanceof Error ? error.message : 'Unknown S3 preset error'
21
- console.warn(`[storage_s3] Failed to apply env preset during tenant setup: ${message}`)
24
+ try {
25
+ await integrationLogService
26
+ .scoped(S3_INTEGRATION_ID, { tenantId, organizationId })
27
+ .error(`Failed to apply S3 env preset during tenant setup: ${message}`)
28
+ } catch (logError) {
29
+ const logMessage = logError instanceof Error ? logError.message : 'Unknown integration log error'
30
+ console.error(
31
+ `[storage_s3] Failed to apply env preset during tenant setup: ${message}. ` +
32
+ `Also failed to persist the error to integration logs: ${logMessage}`,
33
+ )
34
+ }
22
35
  }
23
36
  },
24
37
  }