@open-mercato/core 0.6.6-develop.6343.1.8120f84f30 → 0.6.6-develop.6345.1.b236ea3d4b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/integrations/api/[id]/credentials/route.js +14 -4
- package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-masking.js +50 -0
- package/dist/modules/integrations/lib/credentials-masking.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/integrations/api/[id]/credentials/route.ts +18 -4
- package/src/modules/integrations/lib/credentials-masking.ts +95 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
isCredentialsEncryptionUnavailableError
|
|
12
12
|
} from "../../../lib/credentials-service.js";
|
|
13
13
|
import { collectCredentialUrlValidationErrors } from "../../../lib/credentials-field-validation.js";
|
|
14
|
+
import {
|
|
15
|
+
maskSecretCredentials,
|
|
16
|
+
mergeMaskedSecretCredentials
|
|
17
|
+
} from "../../../lib/credentials-masking.js";
|
|
14
18
|
import {
|
|
15
19
|
resolveUserFeatures,
|
|
16
20
|
runIntegrationMutationGuardAfterSuccess,
|
|
@@ -60,10 +64,13 @@ async function GET(req, ctx) {
|
|
|
60
64
|
}
|
|
61
65
|
throw error;
|
|
62
66
|
}
|
|
67
|
+
const schema = credentialsService.getSchema(integration.id);
|
|
68
|
+
const { credentials, secretFieldsConfigured } = maskSecretCredentials(schema, values ?? {});
|
|
63
69
|
return NextResponse.json({
|
|
64
70
|
integrationId: integration.id,
|
|
65
|
-
schema
|
|
66
|
-
credentials
|
|
71
|
+
schema,
|
|
72
|
+
credentials,
|
|
73
|
+
secretFieldsConfigured,
|
|
67
74
|
updatedAt: updatedAt?.toISOString() ?? null
|
|
68
75
|
});
|
|
69
76
|
}
|
|
@@ -116,6 +123,7 @@ async function PUT(req, ctx) {
|
|
|
116
123
|
}
|
|
117
124
|
const credentialsService = container.resolve("integrationCredentialsService");
|
|
118
125
|
const scope = { organizationId: auth.orgId, tenantId: auth.tenantId };
|
|
126
|
+
const schema = credentialsService.getSchema(integration.id);
|
|
119
127
|
try {
|
|
120
128
|
const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope);
|
|
121
129
|
enforceCommandOptimisticLock({
|
|
@@ -134,7 +142,7 @@ async function PUT(req, ctx) {
|
|
|
134
142
|
throw error;
|
|
135
143
|
}
|
|
136
144
|
const credentialFieldErrors = collectCredentialUrlValidationErrors(
|
|
137
|
-
|
|
145
|
+
schema,
|
|
138
146
|
payloadData.credentials
|
|
139
147
|
);
|
|
140
148
|
if (Object.keys(credentialFieldErrors).length > 0) {
|
|
@@ -144,7 +152,9 @@ async function PUT(req, ctx) {
|
|
|
144
152
|
);
|
|
145
153
|
}
|
|
146
154
|
try {
|
|
147
|
-
await credentialsService.
|
|
155
|
+
const existing = await credentialsService.resolve(integration.id, scope);
|
|
156
|
+
const credentialsToSave = mergeMaskedSecretCredentials(schema, payloadData.credentials, existing ?? {});
|
|
157
|
+
await credentialsService.save(integration.id, credentialsToSave, scope);
|
|
148
158
|
} catch (error) {
|
|
149
159
|
if (isCredentialsEncryptionUnavailableError(error)) {
|
|
150
160
|
return NextResponse.json({ error: "Integration credentials encryption is unavailable" }, { status: 503 });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/integrations/api/%5Bid%5D/credentials/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getIntegration } from '@open-mercato/shared/modules/integrations/types'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { emitIntegrationsEvent } from '../../../events'\nimport { saveCredentialsSchema } from '../../../data/validators'\nimport {\n isCredentialsEncryptionUnavailableError,\n type CredentialsService,\n} from '../../../lib/credentials-service'\nimport { collectCredentialUrlValidationErrors } from '../../../lib/credentials-field-validation'\nimport {\n resolveUserFeatures,\n runIntegrationMutationGuardAfterSuccess,\n runIntegrationMutationGuards,\n} from '../../guards'\n\nconst idParamsSchema = z.object({ id: z.string().min(1) })\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n}\n\nexport const openApi = {\n tags: ['Integrations'],\n summary: 'Get or save integration credentials',\n}\n\nfunction resolveParams(ctx: { params?: Promise<{ id?: string }> | { id?: string } }): Promise<{ id?: string } | undefined> | { id?: string } | undefined {\n if (!ctx.params) return undefined\n if (typeof (ctx.params as Promise<unknown>).then === 'function') {\n return ctx.params as Promise<{ id?: string }>\n }\n return ctx.params as { id?: string }\n}\n\nexport async function GET(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }\n\n let values: Record<string, unknown> | null\n let updatedAt: Date | null\n try {\n values = await credentialsService.resolve(integration.id, scope)\n updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n } catch (error) {\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n return NextResponse.json({\n integrationId: integration.id,\n schema
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAC/B,SAAS,oCAAoC;AAC7C,SAAS,uBAAuB;AAChC,SAAS,6BAA6B;AACtC,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,OAEK;AACP,SAAS,4CAA4C;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC;AAElD,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,iCAAiC,EAAE;AAAA,EAC/E,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,iCAAiC,EAAE;AACjF;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,cAAc;AAAA,EACrB,SAAS;AACX;AAEA,SAAS,cAAc,KAAkI;AACvJ,MAAI,CAAC,IAAI,OAAQ,QAAO;AACxB,MAAI,OAAQ,IAAI,OAA4B,SAAS,YAAY;AAC/D,WAAO,IAAI;AAAA,EACb;AACA,SAAO,IAAI;AACb;AAEA,eAAsB,IAAI,KAAc,KAA8D;AACpG,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,YAAY,MAAM,cAAc,GAAG;AACzC,QAAM,eAAe,eAAe,UAAU,SAAS;AACvD,MAAI,CAAC,aAAa,SAAS;AACzB,WAAO,aAAa,KAAK,EAAE,OAAO,yBAAyB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/E;AAEA,QAAM,cAAc,eAAe,aAAa,KAAK,EAAE;AACvD,MAAI,CAAC,aAAa;AAChB,WAAO,aAAa,KAAK,EAAE,OAAO,wBAAwB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9E;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,qBAAqB,UAAU,QAAQ,+BAA+B;AAC5E,QAAM,QAAQ,EAAE,gBAAgB,KAAK,OAAiB,UAAU,KAAK,SAAS;AAE9E,MAAI;AACJ,MAAI;AACJ,MAAI;AACF,aAAS,MAAM,mBAAmB,QAAQ,YAAY,IAAI,KAAK;AAC/D,gBAAY,MAAM,mBAAmB,iBAAiB,YAAY,IAAI,KAAK;AAAA,EAC7E,SAAS,OAAO;AACd,QAAI,wCAAwC,KAAK,GAAG;AAClD,aAAO,aAAa,KAAK,EAAE,OAAO,oDAAoD,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC1G;AACA,UAAM;AAAA,EACR;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,eAAe,YAAY;AAAA,IAC3B,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getIntegration } from '@open-mercato/shared/modules/integrations/types'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { emitIntegrationsEvent } from '../../../events'\nimport { saveCredentialsSchema } from '../../../data/validators'\nimport {\n isCredentialsEncryptionUnavailableError,\n type CredentialsService,\n} from '../../../lib/credentials-service'\nimport { collectCredentialUrlValidationErrors } from '../../../lib/credentials-field-validation'\nimport {\n maskSecretCredentials,\n mergeMaskedSecretCredentials,\n} from '../../../lib/credentials-masking'\nimport {\n resolveUserFeatures,\n runIntegrationMutationGuardAfterSuccess,\n runIntegrationMutationGuards,\n} from '../../guards'\n\nconst idParamsSchema = z.object({ id: z.string().min(1) })\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },\n}\n\nexport const openApi = {\n tags: ['Integrations'],\n summary: 'Get or save integration credentials',\n}\n\nfunction resolveParams(ctx: { params?: Promise<{ id?: string }> | { id?: string } }): Promise<{ id?: string } | undefined> | { id?: string } | undefined {\n if (!ctx.params) return undefined\n if (typeof (ctx.params as Promise<unknown>).then === 'function') {\n return ctx.params as Promise<{ id?: string }>\n }\n return ctx.params as { id?: string }\n}\n\nexport async function GET(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }\n\n let values: Record<string, unknown> | null\n let updatedAt: Date | null\n try {\n values = await credentialsService.resolve(integration.id, scope)\n updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n } catch (error) {\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n const schema = credentialsService.getSchema(integration.id)\n const { credentials, secretFieldsConfigured } = maskSecretCredentials(schema, values ?? {})\n\n return NextResponse.json({\n integrationId: integration.id,\n schema,\n credentials,\n secretFieldsConfigured,\n updatedAt: updatedAt?.toISOString() ?? null,\n })\n}\n\nexport async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = await resolveParams(ctx)\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const payload = await req.json().catch(() => null)\n const parsedBody = saveCredentialsSchema.safeParse(payload)\n if (!parsedBody.success) {\n return NextResponse.json({ error: 'Invalid credentials payload', details: parsedBody.error.flatten() }, { status: 422 })\n }\n\n const container = await createRequestContainer()\n const guardResult = await runIntegrationMutationGuards(\n container,\n {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: parsedBody.data as Record<string, unknown>,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })\n }\n\n let payloadData = parsedBody.data\n if (guardResult.modifiedPayload) {\n const mergedPayload = { ...parsedBody.data, ...guardResult.modifiedPayload }\n const reparsed = saveCredentialsSchema.safeParse(mergedPayload)\n if (!reparsed.success) {\n return NextResponse.json({ error: 'Invalid credentials payload after guard transform', details: reparsed.error.flatten() }, { status: 422 })\n }\n payloadData = reparsed.data\n }\n\n const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService\n const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }\n const schema = credentialsService.getSchema(integration.id)\n\n try {\n const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)\n enforceCommandOptimisticLock({\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n current: currentUpdatedAt,\n request: req,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n const credentialFieldErrors = collectCredentialUrlValidationErrors(\n schema,\n payloadData.credentials,\n )\n if (Object.keys(credentialFieldErrors).length > 0) {\n return NextResponse.json(\n { error: 'Invalid credentials payload', details: { fieldErrors: credentialFieldErrors } },\n { status: 422 },\n )\n }\n\n try {\n // Secret fields are returned masked on GET; when the client round-trips the\n // mask sentinel it means \"unchanged\", so restore the existing stored secret\n // instead of overwriting it with the placeholder.\n const existing = await credentialsService.resolve(integration.id, scope)\n const credentialsToSave = mergeMaskedSecretCredentials(schema, payloadData.credentials, existing ?? {})\n await credentialsService.save(integration.id, credentialsToSave, scope)\n } catch (error) {\n if (isCredentialsEncryptionUnavailableError(error)) {\n return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })\n }\n throw error\n }\n\n await emitIntegrationsEvent('integrations.credentials.updated', {\n integrationId: integration.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub,\n })\n\n await runIntegrationMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n\n return NextResponse.json({ ok: true })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const SECRET_CREDENTIAL_FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
2
|
+
"secret",
|
|
3
|
+
"oauth",
|
|
4
|
+
"ssh_keypair"
|
|
5
|
+
]);
|
|
6
|
+
const MASKED_SECRET_VALUE = "__om_secret_unchanged__";
|
|
7
|
+
function isSecretField(type) {
|
|
8
|
+
return SECRET_CREDENTIAL_FIELD_TYPES.has(type);
|
|
9
|
+
}
|
|
10
|
+
function hasPresentValue(value) {
|
|
11
|
+
if (value === void 0 || value === null) return false;
|
|
12
|
+
if (typeof value === "string") return value.length > 0;
|
|
13
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
function maskSecretCredentials(schema, values) {
|
|
17
|
+
const credentials = { ...values };
|
|
18
|
+
const secretFieldsConfigured = {};
|
|
19
|
+
for (const field of schema?.fields ?? []) {
|
|
20
|
+
if (!isSecretField(field.type)) continue;
|
|
21
|
+
const configured = hasPresentValue(credentials[field.key]);
|
|
22
|
+
secretFieldsConfigured[field.key] = configured;
|
|
23
|
+
if (configured) {
|
|
24
|
+
credentials[field.key] = MASKED_SECRET_VALUE;
|
|
25
|
+
} else {
|
|
26
|
+
delete credentials[field.key];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { credentials, secretFieldsConfigured };
|
|
30
|
+
}
|
|
31
|
+
function mergeMaskedSecretCredentials(schema, incoming, existing) {
|
|
32
|
+
const merged = { ...incoming };
|
|
33
|
+
for (const field of schema?.fields ?? []) {
|
|
34
|
+
if (!isSecretField(field.type)) continue;
|
|
35
|
+
if (merged[field.key] !== MASKED_SECRET_VALUE) continue;
|
|
36
|
+
if (hasPresentValue(existing[field.key])) {
|
|
37
|
+
merged[field.key] = existing[field.key];
|
|
38
|
+
} else {
|
|
39
|
+
delete merged[field.key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return merged;
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
MASKED_SECRET_VALUE,
|
|
46
|
+
SECRET_CREDENTIAL_FIELD_TYPES,
|
|
47
|
+
maskSecretCredentials,
|
|
48
|
+
mergeMaskedSecretCredentials
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=credentials-masking.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/integrations/lib/credentials-masking.ts"],
|
|
4
|
+
"sourcesContent": ["import type {\n CredentialFieldType,\n IntegrationCredentialsSchema,\n} from '@open-mercato/shared/modules/integrations/types'\n\n/**\n * Credential field types whose stored value is a secret (API keys, OAuth client\n * secrets/tokens, SSH private keys). These MUST never be returned in plaintext\n * from the credentials API \u2014 they are masked on read and treated as write-only.\n */\nexport const SECRET_CREDENTIAL_FIELD_TYPES: ReadonlySet<CredentialFieldType> = new Set<CredentialFieldType>([\n 'secret',\n 'oauth',\n 'ssh_keypair',\n])\n\n/**\n * Opaque sentinel returned in place of a configured secret value. The client\n * round-trips this value back on save when the user did not change the field;\n * the PUT handler then preserves the existing stored secret instead of writing\n * the sentinel. Chosen to be extremely unlikely to collide with a real secret.\n */\nexport const MASKED_SECRET_VALUE = '__om_secret_unchanged__'\n\nfunction isSecretField(type: CredentialFieldType): boolean {\n return SECRET_CREDENTIAL_FIELD_TYPES.has(type)\n}\n\nfunction hasPresentValue(value: unknown): boolean {\n if (value === undefined || value === null) return false\n if (typeof value === 'string') return value.length > 0\n if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length > 0\n return true\n}\n\nexport type MaskSecretCredentialsResult = {\n credentials: Record<string, unknown>\n secretFieldsConfigured: Record<string, boolean>\n}\n\n/**\n * Replace every configured secret-typed field with an opaque sentinel so the\n * decrypted plaintext never reaches the API response (and therefore the\n * browser, devtools, proxies, or editable DOM inputs). Non-secret config fields\n * pass through unchanged. A `secretFieldsConfigured` map reports which secret\n * fields currently hold a value without exposing it.\n */\nexport function maskSecretCredentials(\n schema: IntegrationCredentialsSchema | undefined,\n values: Record<string, unknown>,\n): MaskSecretCredentialsResult {\n const credentials: Record<string, unknown> = { ...values }\n const secretFieldsConfigured: Record<string, boolean> = {}\n\n for (const field of schema?.fields ?? []) {\n if (!isSecretField(field.type)) continue\n const configured = hasPresentValue(credentials[field.key])\n secretFieldsConfigured[field.key] = configured\n if (configured) {\n credentials[field.key] = MASKED_SECRET_VALUE\n } else {\n delete credentials[field.key]\n }\n }\n\n return { credentials, secretFieldsConfigured }\n}\n\n/**\n * Reverse of {@link maskSecretCredentials} for the save path. When the client\n * submits the mask sentinel for a secret field it means \"leave it unchanged\":\n * restore the existing stored secret, or drop the field entirely when nothing\n * was previously stored (so the literal sentinel is never persisted). Any other\n * value (including an empty string, which clears the secret) is written as-is.\n */\nexport function mergeMaskedSecretCredentials(\n schema: IntegrationCredentialsSchema | undefined,\n incoming: Record<string, unknown>,\n existing: Record<string, unknown>,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...incoming }\n\n for (const field of schema?.fields ?? []) {\n if (!isSecretField(field.type)) continue\n if (merged[field.key] !== MASKED_SECRET_VALUE) continue\n\n if (hasPresentValue(existing[field.key])) {\n merged[field.key] = existing[field.key]\n } else {\n delete merged[field.key]\n }\n }\n\n return merged\n}\n"],
|
|
5
|
+
"mappings": "AAUO,MAAM,gCAAkE,oBAAI,IAAyB;AAAA,EAC1G;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAQM,MAAM,sBAAsB;AAEnC,SAAS,cAAc,MAAoC;AACzD,SAAO,8BAA8B,IAAI,IAAI;AAC/C;AAEA,SAAS,gBAAgB,OAAyB;AAChD,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO,MAAM,SAAS;AACrD,MAAI,OAAO,UAAU,SAAU,QAAO,OAAO,KAAK,KAAgC,EAAE,SAAS;AAC7F,SAAO;AACT;AAcO,SAAS,sBACd,QACA,QAC6B;AAC7B,QAAM,cAAuC,EAAE,GAAG,OAAO;AACzD,QAAM,yBAAkD,CAAC;AAEzD,aAAW,SAAS,QAAQ,UAAU,CAAC,GAAG;AACxC,QAAI,CAAC,cAAc,MAAM,IAAI,EAAG;AAChC,UAAM,aAAa,gBAAgB,YAAY,MAAM,GAAG,CAAC;AACzD,2BAAuB,MAAM,GAAG,IAAI;AACpC,QAAI,YAAY;AACd,kBAAY,MAAM,GAAG,IAAI;AAAA,IAC3B,OAAO;AACL,aAAO,YAAY,MAAM,GAAG;AAAA,IAC9B;AAAA,EACF;AAEA,SAAO,EAAE,aAAa,uBAAuB;AAC/C;AASO,SAAS,6BACd,QACA,UACA,UACyB;AACzB,QAAM,SAAkC,EAAE,GAAG,SAAS;AAEtD,aAAW,SAAS,QAAQ,UAAU,CAAC,GAAG;AACxC,QAAI,CAAC,cAAc,MAAM,IAAI,EAAG;AAChC,QAAI,OAAO,MAAM,GAAG,MAAM,oBAAqB;AAE/C,QAAI,gBAAgB,SAAS,MAAM,GAAG,CAAC,GAAG;AACxC,aAAO,MAAM,GAAG,IAAI,SAAS,MAAM,GAAG;AAAA,IACxC,OAAO;AACL,aAAO,OAAO,MAAM,GAAG;AAAA,IACzB;AAAA,EACF;AAEA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6345.1.b236ea3d4b",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
type CredentialsService,
|
|
13
13
|
} from '../../../lib/credentials-service'
|
|
14
14
|
import { collectCredentialUrlValidationErrors } from '../../../lib/credentials-field-validation'
|
|
15
|
+
import {
|
|
16
|
+
maskSecretCredentials,
|
|
17
|
+
mergeMaskedSecretCredentials,
|
|
18
|
+
} from '../../../lib/credentials-masking'
|
|
15
19
|
import {
|
|
16
20
|
resolveUserFeatures,
|
|
17
21
|
runIntegrationMutationGuardAfterSuccess,
|
|
@@ -71,10 +75,14 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
71
75
|
throw error
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
const schema = credentialsService.getSchema(integration.id)
|
|
79
|
+
const { credentials, secretFieldsConfigured } = maskSecretCredentials(schema, values ?? {})
|
|
80
|
+
|
|
74
81
|
return NextResponse.json({
|
|
75
82
|
integrationId: integration.id,
|
|
76
|
-
schema
|
|
77
|
-
credentials
|
|
83
|
+
schema,
|
|
84
|
+
credentials,
|
|
85
|
+
secretFieldsConfigured,
|
|
78
86
|
updatedAt: updatedAt?.toISOString() ?? null,
|
|
79
87
|
})
|
|
80
88
|
}
|
|
@@ -134,6 +142,7 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
134
142
|
|
|
135
143
|
const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService
|
|
136
144
|
const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
|
|
145
|
+
const schema = credentialsService.getSchema(integration.id)
|
|
137
146
|
|
|
138
147
|
try {
|
|
139
148
|
const currentUpdatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
|
|
@@ -154,7 +163,7 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
const credentialFieldErrors = collectCredentialUrlValidationErrors(
|
|
157
|
-
|
|
166
|
+
schema,
|
|
158
167
|
payloadData.credentials,
|
|
159
168
|
)
|
|
160
169
|
if (Object.keys(credentialFieldErrors).length > 0) {
|
|
@@ -165,7 +174,12 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
|
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
try {
|
|
168
|
-
|
|
177
|
+
// Secret fields are returned masked on GET; when the client round-trips the
|
|
178
|
+
// mask sentinel it means "unchanged", so restore the existing stored secret
|
|
179
|
+
// instead of overwriting it with the placeholder.
|
|
180
|
+
const existing = await credentialsService.resolve(integration.id, scope)
|
|
181
|
+
const credentialsToSave = mergeMaskedSecretCredentials(schema, payloadData.credentials, existing ?? {})
|
|
182
|
+
await credentialsService.save(integration.id, credentialsToSave, scope)
|
|
169
183
|
} catch (error) {
|
|
170
184
|
if (isCredentialsEncryptionUnavailableError(error)) {
|
|
171
185
|
return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CredentialFieldType,
|
|
3
|
+
IntegrationCredentialsSchema,
|
|
4
|
+
} from '@open-mercato/shared/modules/integrations/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Credential field types whose stored value is a secret (API keys, OAuth client
|
|
8
|
+
* secrets/tokens, SSH private keys). These MUST never be returned in plaintext
|
|
9
|
+
* from the credentials API — they are masked on read and treated as write-only.
|
|
10
|
+
*/
|
|
11
|
+
export const SECRET_CREDENTIAL_FIELD_TYPES: ReadonlySet<CredentialFieldType> = new Set<CredentialFieldType>([
|
|
12
|
+
'secret',
|
|
13
|
+
'oauth',
|
|
14
|
+
'ssh_keypair',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opaque sentinel returned in place of a configured secret value. The client
|
|
19
|
+
* round-trips this value back on save when the user did not change the field;
|
|
20
|
+
* the PUT handler then preserves the existing stored secret instead of writing
|
|
21
|
+
* the sentinel. Chosen to be extremely unlikely to collide with a real secret.
|
|
22
|
+
*/
|
|
23
|
+
export const MASKED_SECRET_VALUE = '__om_secret_unchanged__'
|
|
24
|
+
|
|
25
|
+
function isSecretField(type: CredentialFieldType): boolean {
|
|
26
|
+
return SECRET_CREDENTIAL_FIELD_TYPES.has(type)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasPresentValue(value: unknown): boolean {
|
|
30
|
+
if (value === undefined || value === null) return false
|
|
31
|
+
if (typeof value === 'string') return value.length > 0
|
|
32
|
+
if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length > 0
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type MaskSecretCredentialsResult = {
|
|
37
|
+
credentials: Record<string, unknown>
|
|
38
|
+
secretFieldsConfigured: Record<string, boolean>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Replace every configured secret-typed field with an opaque sentinel so the
|
|
43
|
+
* decrypted plaintext never reaches the API response (and therefore the
|
|
44
|
+
* browser, devtools, proxies, or editable DOM inputs). Non-secret config fields
|
|
45
|
+
* pass through unchanged. A `secretFieldsConfigured` map reports which secret
|
|
46
|
+
* fields currently hold a value without exposing it.
|
|
47
|
+
*/
|
|
48
|
+
export function maskSecretCredentials(
|
|
49
|
+
schema: IntegrationCredentialsSchema | undefined,
|
|
50
|
+
values: Record<string, unknown>,
|
|
51
|
+
): MaskSecretCredentialsResult {
|
|
52
|
+
const credentials: Record<string, unknown> = { ...values }
|
|
53
|
+
const secretFieldsConfigured: Record<string, boolean> = {}
|
|
54
|
+
|
|
55
|
+
for (const field of schema?.fields ?? []) {
|
|
56
|
+
if (!isSecretField(field.type)) continue
|
|
57
|
+
const configured = hasPresentValue(credentials[field.key])
|
|
58
|
+
secretFieldsConfigured[field.key] = configured
|
|
59
|
+
if (configured) {
|
|
60
|
+
credentials[field.key] = MASKED_SECRET_VALUE
|
|
61
|
+
} else {
|
|
62
|
+
delete credentials[field.key]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { credentials, secretFieldsConfigured }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reverse of {@link maskSecretCredentials} for the save path. When the client
|
|
71
|
+
* submits the mask sentinel for a secret field it means "leave it unchanged":
|
|
72
|
+
* restore the existing stored secret, or drop the field entirely when nothing
|
|
73
|
+
* was previously stored (so the literal sentinel is never persisted). Any other
|
|
74
|
+
* value (including an empty string, which clears the secret) is written as-is.
|
|
75
|
+
*/
|
|
76
|
+
export function mergeMaskedSecretCredentials(
|
|
77
|
+
schema: IntegrationCredentialsSchema | undefined,
|
|
78
|
+
incoming: Record<string, unknown>,
|
|
79
|
+
existing: Record<string, unknown>,
|
|
80
|
+
): Record<string, unknown> {
|
|
81
|
+
const merged: Record<string, unknown> = { ...incoming }
|
|
82
|
+
|
|
83
|
+
for (const field of schema?.fields ?? []) {
|
|
84
|
+
if (!isSecretField(field.type)) continue
|
|
85
|
+
if (merged[field.key] !== MASKED_SECRET_VALUE) continue
|
|
86
|
+
|
|
87
|
+
if (hasPresentValue(existing[field.key])) {
|
|
88
|
+
merged[field.key] = existing[field.key]
|
|
89
|
+
} else {
|
|
90
|
+
delete merged[field.key]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return merged
|
|
95
|
+
}
|