@open-mercato/core 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5617.1.62538c48ca
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/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/generated/entities/attachment_partition/index.js +4 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +3 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/api_keys/data/entities.js +9 -2
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20260523234901.js +17 -0
- package/dist/modules/api_keys/migrations/Migration20260523234901.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +26 -0
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +54 -12
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +7 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20260524000000.js +27 -0
- package/dist/modules/attachments/migrations/Migration20260524000000.js.map +7 -0
- package/dist/modules/auth/api/roles/route.js +6 -35
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/cli.js +26 -4
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +34 -11
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/lib/roleTenantGuard.js +41 -13
- package/dist/modules/auth/lib/roleTenantGuard.js.map +3 -3
- package/dist/modules/auth/lib/setup-app.js +88 -26
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/currencies/api/fetch-configs/route.js +4 -2
- package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
- package/dist/modules/integrations/api/[id]/route.js +5 -1
- package/dist/modules/integrations/api/[id]/route.js.map +2 -2
- package/dist/modules/integrations/api/route.js +5 -1
- package/dist/modules/integrations/api/route.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +6 -4
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/generated/entities/attachment_partition/index.ts +2 -0
- package/generated/entity-fields-registry.ts +3 -0
- package/package.json +7 -7
- package/src/modules/api_keys/data/entities.ts +15 -1
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +18 -0
- package/src/modules/api_keys/migrations/Migration20260523234901.ts +17 -0
- package/src/modules/api_keys/services/apiKeyService.ts +63 -0
- package/src/modules/attachments/api/partitions/route.ts +58 -11
- package/src/modules/attachments/data/entities.ts +7 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +28 -0
- package/src/modules/attachments/migrations/Migration20260524000000.ts +25 -0
- package/src/modules/auth/api/roles/route.ts +7 -38
- package/src/modules/auth/cli.ts +27 -4
- package/src/modules/auth/commands/roles.ts +39 -11
- package/src/modules/auth/lib/roleTenantGuard.ts +56 -17
- package/src/modules/auth/lib/setup-app.ts +160 -32
- package/src/modules/currencies/api/fetch-configs/route.ts +4 -2
- package/src/modules/integrations/api/[id]/route.ts +5 -2
- package/src/modules/integrations/api/route.ts +5 -2
- package/src/modules/integrations/lib/credentials-service.ts +15 -5
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.5617.1.62538c48ca",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -245,16 +245,16 @@
|
|
|
245
245
|
"zod": "^4.4.3"
|
|
246
246
|
},
|
|
247
247
|
"peerDependencies": {
|
|
248
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
249
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
248
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.5617.1.62538c48ca",
|
|
249
|
+
"@open-mercato/shared": "0.6.6-develop.5617.1.62538c48ca",
|
|
250
|
+
"@open-mercato/ui": "0.6.6-develop.5617.1.62538c48ca",
|
|
251
251
|
"react": "^19.0.0",
|
|
252
252
|
"react-dom": "^19.0.0"
|
|
253
253
|
},
|
|
254
254
|
"devDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.5617.1.62538c48ca",
|
|
256
|
+
"@open-mercato/shared": "0.6.6-develop.5617.1.62538c48ca",
|
|
257
|
+
"@open-mercato/ui": "0.6.6-develop.5617.1.62538c48ca",
|
|
258
258
|
"@testing-library/dom": "^10.4.1",
|
|
259
259
|
"@testing-library/jest-dom": "^6.9.1",
|
|
260
260
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
|
|
1
|
+
import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
|
|
2
2
|
|
|
3
3
|
@Entity({ tableName: 'api_keys' })
|
|
4
4
|
@Unique({ properties: ['keyPrefix'] })
|
|
5
|
+
@Index({
|
|
6
|
+
name: 'api_keys_opencode_session_id_uq',
|
|
7
|
+
expression:
|
|
8
|
+
'create unique index "api_keys_opencode_session_id_uq" on "api_keys" ("opencode_session_id") where "opencode_session_id" is not null and "deleted_at" is null',
|
|
9
|
+
})
|
|
5
10
|
export class ApiKey {
|
|
6
11
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
7
12
|
id!: string
|
|
@@ -42,6 +47,15 @@ export class ApiKey {
|
|
|
42
47
|
@Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })
|
|
43
48
|
sessionSecretEncrypted?: string | null
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* OpenCode session id bound to this api_key row. Set the first time the
|
|
52
|
+
* chat dispatcher receives a `done` event after minting a session token,
|
|
53
|
+
* then asserted on every subsequent resume to prevent cross-user session
|
|
54
|
+
* continuation (see security fix 2026-05-23).
|
|
55
|
+
*/
|
|
56
|
+
@Property({ name: 'opencode_session_id', type: 'text', nullable: true })
|
|
57
|
+
opencodeSessionId?: string | null
|
|
58
|
+
|
|
45
59
|
@Property({ name: 'last_used_at', type: Date, nullable: true })
|
|
46
60
|
lastUsedAt?: Date | null
|
|
47
61
|
|
|
@@ -115,6 +115,15 @@
|
|
|
115
115
|
"nullable": true,
|
|
116
116
|
"mappedType": "text"
|
|
117
117
|
},
|
|
118
|
+
"opencode_session_id": {
|
|
119
|
+
"name": "opencode_session_id",
|
|
120
|
+
"type": "text",
|
|
121
|
+
"unsigned": false,
|
|
122
|
+
"autoincrement": false,
|
|
123
|
+
"primary": false,
|
|
124
|
+
"nullable": true,
|
|
125
|
+
"mappedType": "text"
|
|
126
|
+
},
|
|
118
127
|
"last_used_at": {
|
|
119
128
|
"name": "last_used_at",
|
|
120
129
|
"type": "timestamptz",
|
|
@@ -188,6 +197,15 @@
|
|
|
188
197
|
"constraint": true,
|
|
189
198
|
"primary": true,
|
|
190
199
|
"unique": true
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"columnNames": [],
|
|
203
|
+
"composite": false,
|
|
204
|
+
"constraint": false,
|
|
205
|
+
"keyName": "api_keys_opencode_session_id_uq",
|
|
206
|
+
"primary": false,
|
|
207
|
+
"unique": false,
|
|
208
|
+
"expression": "create unique index \"api_keys_opencode_session_id_uq\" on \"api_keys\" (\"opencode_session_id\") where \"opencode_session_id\" is not null and \"deleted_at\" is null"
|
|
191
209
|
}
|
|
192
210
|
],
|
|
193
211
|
"checks": [],
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260523234901 extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`alter table "api_keys" add column "opencode_session_id" text null;`);
|
|
7
|
+
this.addSql(
|
|
8
|
+
`create unique index "api_keys_opencode_session_id_uq" on "api_keys" ("opencode_session_id") where "opencode_session_id" is not null and "deleted_at" is null;`
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
override async down(): Promise<void> {
|
|
13
|
+
this.addSql(`drop index "api_keys_opencode_session_id_uq";`);
|
|
14
|
+
this.addSql(`alter table "api_keys" drop column "opencode_session_id";`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
}
|
|
@@ -7,6 +7,7 @@ import { ApiKey } from '../data/entities'
|
|
|
7
7
|
import { createKmsService } from '@open-mercato/shared/lib/encryption/kms'
|
|
8
8
|
import { encryptWithAesGcm, decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
|
|
9
9
|
import { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'
|
|
10
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
10
11
|
|
|
11
12
|
const BCRYPT_COST = 10
|
|
12
13
|
|
|
@@ -230,6 +231,68 @@ export async function findApiKeyBySessionToken(
|
|
|
230
231
|
return record
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Bind an OpenCode session id to the api_key row that owns this chat session.
|
|
236
|
+
*
|
|
237
|
+
* Called by the chat dispatcher the first time we see the `done` event for a
|
|
238
|
+
* freshly minted session token. From that point on,
|
|
239
|
+
* `findApiKeyByOpencodeSessionId(em, opencodeSessionId)` returns the same row,
|
|
240
|
+
* which the ai-assistant runtime uses to assert ownership on every resume.
|
|
241
|
+
*
|
|
242
|
+
* Throws when the session token has been deleted/expired, and when the api_key
|
|
243
|
+
* row is already bound to a DIFFERENT OpenCode session (defensive: this should
|
|
244
|
+
* never happen in practice because each chat mints a new session token, but we
|
|
245
|
+
* fail closed instead of silently overwriting).
|
|
246
|
+
*
|
|
247
|
+
* Idempotent when the row is already bound to the same OpenCode session id.
|
|
248
|
+
*/
|
|
249
|
+
export async function bindOpencodeSessionToApiKey(
|
|
250
|
+
em: EntityManager,
|
|
251
|
+
sessionToken: string,
|
|
252
|
+
opencodeSessionId: string
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
if (!sessionToken) throw new Error('Session token not found or expired')
|
|
255
|
+
if (!opencodeSessionId) throw new Error('OpenCode session id is required')
|
|
256
|
+
|
|
257
|
+
const row = await findApiKeyBySessionToken(em, sessionToken)
|
|
258
|
+
if (!row) throw new Error('Session token not found or expired')
|
|
259
|
+
|
|
260
|
+
if (row.opencodeSessionId === opencodeSessionId) return
|
|
261
|
+
if (row.opencodeSessionId && row.opencodeSessionId !== opencodeSessionId) {
|
|
262
|
+
throw new Error('Session token already bound to a different OpenCode session')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
row.opencodeSessionId = opencodeSessionId
|
|
266
|
+
await em.persist(row).flush()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Find an api_key row by its bound OpenCode session id.
|
|
271
|
+
*
|
|
272
|
+
* Returns null if no active row matches, or if the matched row is expired
|
|
273
|
+
* (same contract as `findApiKeyBySessionToken`). Uses
|
|
274
|
+
* `findOneWithDecryption` so encrypted-at-rest fields on the row are decrypted
|
|
275
|
+
* before the ai-assistant runtime inspects `sessionUserId` / `tenantId` /
|
|
276
|
+
* `organizationId` for the ownership check.
|
|
277
|
+
*/
|
|
278
|
+
export async function findApiKeyByOpencodeSessionId(
|
|
279
|
+
em: EntityManager,
|
|
280
|
+
opencodeSessionId: string
|
|
281
|
+
): Promise<ApiKey | null> {
|
|
282
|
+
if (!opencodeSessionId) return null
|
|
283
|
+
|
|
284
|
+
const record = await findOneWithDecryption(
|
|
285
|
+
em,
|
|
286
|
+
ApiKey,
|
|
287
|
+
{ opencodeSessionId, deletedAt: null } as any,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if (!record) return null
|
|
291
|
+
if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null
|
|
292
|
+
|
|
293
|
+
return record
|
|
294
|
+
}
|
|
295
|
+
|
|
233
296
|
/**
|
|
234
297
|
* Find a session API key with its decrypted secret.
|
|
235
298
|
* Returns null if not found, expired, deleted, or decryption fails.
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
7
|
import { Attachment, AttachmentPartition } from '../../data/entities'
|
|
7
8
|
import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'
|
|
8
9
|
import { resolvePartitionEnvKey } from '../../lib/partitionEnv'
|
|
@@ -23,6 +24,24 @@ const deleteSchema = z.object({
|
|
|
23
24
|
|
|
24
25
|
const DEFAULT_CODES = new Set(DEFAULT_ATTACHMENT_PARTITIONS.map((entry) => entry.code))
|
|
25
26
|
|
|
27
|
+
type AuthLike = { sub?: string; tenantId?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null | undefined
|
|
28
|
+
|
|
29
|
+
function isSuperAdmin(auth: AuthLike): boolean {
|
|
30
|
+
return auth?.isSuperAdmin === true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function canManagePartition(auth: AuthLike, entry: AttachmentPartition): boolean {
|
|
34
|
+
if (!auth?.tenantId) return false
|
|
35
|
+
if (entry.tenantId && entry.tenantId === auth.tenantId) return true
|
|
36
|
+
if (entry.tenantId == null && isSuperAdmin(auth)) return true
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function partitionVisibilityFilter(auth: AuthLike) {
|
|
41
|
+
if (isSuperAdmin(auth)) return {}
|
|
42
|
+
return { $or: [{ tenantId: null }, { tenantId: auth?.tenantId ?? null }] }
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
function serializePartition(entry: AttachmentPartition) {
|
|
27
46
|
return {
|
|
28
47
|
id: entry.id,
|
|
@@ -54,12 +73,18 @@ async function resolveEm() {
|
|
|
54
73
|
|
|
55
74
|
export async function GET(req: Request) {
|
|
56
75
|
const auth = await getAuthFromRequest(req)
|
|
57
|
-
if (!auth?.sub) {
|
|
76
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
58
77
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
59
78
|
}
|
|
60
79
|
const em = await resolveEm()
|
|
61
80
|
await ensureDefaultPartitions(em)
|
|
62
|
-
const rows = await
|
|
81
|
+
const rows = await findWithDecryption(
|
|
82
|
+
em,
|
|
83
|
+
AttachmentPartition,
|
|
84
|
+
partitionVisibilityFilter(auth),
|
|
85
|
+
{ orderBy: { createdAt: 'asc' } },
|
|
86
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
87
|
+
)
|
|
63
88
|
return NextResponse.json({ items: rows.map((entry) => serializePartition(entry)) })
|
|
64
89
|
}
|
|
65
90
|
|
|
@@ -71,7 +96,7 @@ export async function POST(req: Request) {
|
|
|
71
96
|
)
|
|
72
97
|
}
|
|
73
98
|
const auth = await getAuthFromRequest(req)
|
|
74
|
-
if (!auth?.sub) {
|
|
99
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
75
100
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
76
101
|
}
|
|
77
102
|
let json: unknown = null
|
|
@@ -90,7 +115,13 @@ export async function POST(req: Request) {
|
|
|
90
115
|
}
|
|
91
116
|
const em = await resolveEm()
|
|
92
117
|
await ensureDefaultPartitions(em)
|
|
93
|
-
const exists = await
|
|
118
|
+
const exists = await findOneWithDecryption(
|
|
119
|
+
em,
|
|
120
|
+
AttachmentPartition,
|
|
121
|
+
{ code },
|
|
122
|
+
undefined,
|
|
123
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
124
|
+
)
|
|
94
125
|
if (exists) {
|
|
95
126
|
return NextResponse.json({ error: 'Partition code already exists.' }, { status: 409 })
|
|
96
127
|
}
|
|
@@ -106,6 +137,8 @@ export async function POST(req: Request) {
|
|
|
106
137
|
? parsed.data.requiresOcr
|
|
107
138
|
: resolveDefaultAttachmentOcrEnabled(),
|
|
108
139
|
ocrModel: parsed.data.ocrModel?.trim() || null,
|
|
140
|
+
tenantId: auth.tenantId,
|
|
141
|
+
organizationId: auth.orgId ?? null,
|
|
109
142
|
})
|
|
110
143
|
await em.persist(entry).flush()
|
|
111
144
|
return NextResponse.json({ item: serializePartition(entry) }, { status: 201 })
|
|
@@ -119,7 +152,7 @@ export async function PUT(req: Request) {
|
|
|
119
152
|
)
|
|
120
153
|
}
|
|
121
154
|
const auth = await getAuthFromRequest(req)
|
|
122
|
-
if (!auth?.sub) {
|
|
155
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
123
156
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
124
157
|
}
|
|
125
158
|
let json: unknown = null
|
|
@@ -133,8 +166,14 @@ export async function PUT(req: Request) {
|
|
|
133
166
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
134
167
|
}
|
|
135
168
|
const em = await resolveEm()
|
|
136
|
-
const entry = await
|
|
137
|
-
|
|
169
|
+
const entry = await findOneWithDecryption(
|
|
170
|
+
em,
|
|
171
|
+
AttachmentPartition,
|
|
172
|
+
{ id: parsed.data.id },
|
|
173
|
+
undefined,
|
|
174
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
175
|
+
)
|
|
176
|
+
if (!entry || !canManagePartition(auth, entry)) {
|
|
138
177
|
return NextResponse.json({ error: 'Partition not found' }, { status: 404 })
|
|
139
178
|
}
|
|
140
179
|
if (sanitizePartitionCode(parsed.data.code) !== entry.code) {
|
|
@@ -167,7 +206,7 @@ export async function DELETE(req: Request) {
|
|
|
167
206
|
)
|
|
168
207
|
}
|
|
169
208
|
const auth = await getAuthFromRequest(req)
|
|
170
|
-
if (!auth?.sub) {
|
|
209
|
+
if (!auth?.sub || !auth.tenantId) {
|
|
171
210
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
172
211
|
}
|
|
173
212
|
const url = new URL(req.url)
|
|
@@ -177,14 +216,22 @@ export async function DELETE(req: Request) {
|
|
|
177
216
|
return NextResponse.json({ error: 'Partition id is required' }, { status: 400 })
|
|
178
217
|
}
|
|
179
218
|
const em = await resolveEm()
|
|
180
|
-
const entry = await
|
|
181
|
-
|
|
219
|
+
const entry = await findOneWithDecryption(
|
|
220
|
+
em,
|
|
221
|
+
AttachmentPartition,
|
|
222
|
+
{ id: parsed.data.id },
|
|
223
|
+
undefined,
|
|
224
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
|
|
225
|
+
)
|
|
226
|
+
if (!entry || !canManagePartition(auth, entry)) {
|
|
182
227
|
return NextResponse.json({ error: 'Partition not found' }, { status: 404 })
|
|
183
228
|
}
|
|
184
229
|
if (DEFAULT_CODES.has(entry.code)) {
|
|
185
230
|
return NextResponse.json({ error: 'Default partitions cannot be removed.' }, { status: 400 })
|
|
186
231
|
}
|
|
187
|
-
const
|
|
232
|
+
const usageFilter: Record<string, unknown> = { partitionCode: entry.code }
|
|
233
|
+
if (entry.tenantId) usageFilter.tenantId = entry.tenantId
|
|
234
|
+
const usage = await em.count(Attachment, usageFilter)
|
|
188
235
|
if (usage > 0) {
|
|
189
236
|
return NextResponse.json({ error: 'Partition is in use and cannot be removed.' }, { status: 409 })
|
|
190
237
|
}
|
|
@@ -34,6 +34,13 @@ export class AttachmentPartition {
|
|
|
34
34
|
@Property({ name: 'ocr_model', type: 'text', nullable: true })
|
|
35
35
|
ocrModel?: string | null
|
|
36
36
|
|
|
37
|
+
@Property({ name: 'organization_id', type: 'uuid', nullable: true })
|
|
38
|
+
organizationId?: string | null
|
|
39
|
+
|
|
40
|
+
@Property({ name: 'tenant_id', type: 'uuid', nullable: true })
|
|
41
|
+
@Index({ name: 'attachment_partitions_tenant_idx' })
|
|
42
|
+
tenantId?: string | null
|
|
43
|
+
|
|
37
44
|
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
38
45
|
createdAt: Date = new Date()
|
|
39
46
|
|
|
@@ -270,6 +270,24 @@
|
|
|
270
270
|
"nullable": true,
|
|
271
271
|
"mappedType": "text"
|
|
272
272
|
},
|
|
273
|
+
"organization_id": {
|
|
274
|
+
"name": "organization_id",
|
|
275
|
+
"type": "uuid",
|
|
276
|
+
"unsigned": false,
|
|
277
|
+
"autoincrement": false,
|
|
278
|
+
"primary": false,
|
|
279
|
+
"nullable": true,
|
|
280
|
+
"mappedType": "uuid"
|
|
281
|
+
},
|
|
282
|
+
"tenant_id": {
|
|
283
|
+
"name": "tenant_id",
|
|
284
|
+
"type": "uuid",
|
|
285
|
+
"unsigned": false,
|
|
286
|
+
"autoincrement": false,
|
|
287
|
+
"primary": false,
|
|
288
|
+
"nullable": true,
|
|
289
|
+
"mappedType": "uuid"
|
|
290
|
+
},
|
|
273
291
|
"created_at": {
|
|
274
292
|
"name": "created_at",
|
|
275
293
|
"type": "timestamptz",
|
|
@@ -294,6 +312,16 @@
|
|
|
294
312
|
"name": "attachment_partitions",
|
|
295
313
|
"schema": "public",
|
|
296
314
|
"indexes": [
|
|
315
|
+
{
|
|
316
|
+
"keyName": "attachment_partitions_tenant_idx",
|
|
317
|
+
"columnNames": [
|
|
318
|
+
"tenant_id"
|
|
319
|
+
],
|
|
320
|
+
"composite": false,
|
|
321
|
+
"constraint": false,
|
|
322
|
+
"primary": false,
|
|
323
|
+
"unique": false
|
|
324
|
+
},
|
|
297
325
|
{
|
|
298
326
|
"keyName": "attachment_partitions_code_unique",
|
|
299
327
|
"columnNames": [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
export class Migration20260524000000 extends Migration {
|
|
4
|
+
override async up(): Promise<void> {
|
|
5
|
+
this.addSql(
|
|
6
|
+
`alter table "attachment_partitions" add column "organization_id" uuid null;`,
|
|
7
|
+
)
|
|
8
|
+
this.addSql(
|
|
9
|
+
`alter table "attachment_partitions" add column "tenant_id" uuid null;`,
|
|
10
|
+
)
|
|
11
|
+
this.addSql(
|
|
12
|
+
`create index "attachment_partitions_tenant_idx" on "attachment_partitions" ("tenant_id");`,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override async down(): Promise<void> {
|
|
17
|
+
this.addSql(`drop index if exists "attachment_partitions_tenant_idx";`)
|
|
18
|
+
this.addSql(
|
|
19
|
+
`alter table "attachment_partitions" drop column "tenant_id";`,
|
|
20
|
+
)
|
|
21
|
+
this.addSql(
|
|
22
|
+
`alter table "attachment_partitions" drop column "organization_id";`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -14,11 +14,8 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
14
14
|
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
15
15
|
import { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'
|
|
16
16
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
assertActorCanModifySuperAdminRoleTarget,
|
|
20
|
-
} from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
21
|
-
import { enforceTenantSelection } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
17
|
+
import { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
18
|
+
import { enforceRoleTenantAccess } from '@open-mercato/core/modules/auth/lib/roleTenantGuard'
|
|
22
19
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
23
20
|
|
|
24
21
|
const querySchema = z.object({
|
|
@@ -88,18 +85,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
88
85
|
create: {
|
|
89
86
|
commandId: 'auth.roles.create',
|
|
90
87
|
schema: rawBodySchema,
|
|
91
|
-
mapInput: async ({ parsed, ctx }) =>
|
|
92
|
-
|
|
93
|
-
// roles are unsupported); only resolve string/omitted tenantId against the actor.
|
|
94
|
-
if (parsed.tenantId === null) return parsed
|
|
95
|
-
const requestedTenantId = typeof parsed.tenantId === 'string' ? parsed.tenantId : undefined
|
|
96
|
-
const resolvedTenantId = await enforceTenantSelection(
|
|
97
|
-
{ auth: ctx.auth, container: ctx.container },
|
|
98
|
-
requestedTenantId,
|
|
99
|
-
)
|
|
100
|
-
if (resolvedTenantId) parsed.tenantId = resolvedTenantId
|
|
101
|
-
return parsed
|
|
102
|
-
},
|
|
88
|
+
mapInput: async ({ parsed, ctx }) =>
|
|
89
|
+
enforceRoleTenantAccess('create', parsed, { auth: ctx.auth, container: ctx.container }),
|
|
103
90
|
response: ({ result }) => ({ id: String(result.id) }),
|
|
104
91
|
status: 201,
|
|
105
92
|
},
|
|
@@ -109,9 +96,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
109
96
|
mapInput: async ({ parsed, ctx }) => {
|
|
110
97
|
if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {
|
|
111
98
|
await assertCanModifySuperAdminRole(ctx.request, parsed.id)
|
|
112
|
-
await assertCanAccessRoleTarget(ctx.request, parsed.id)
|
|
113
99
|
}
|
|
114
|
-
return parsed
|
|
100
|
+
return enforceRoleTenantAccess('update', parsed, { auth: ctx.auth, container: ctx.container })
|
|
115
101
|
},
|
|
116
102
|
response: () => ({ ok: true }),
|
|
117
103
|
},
|
|
@@ -121,9 +107,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
121
107
|
const targetId = resolveDeleteTargetId(parsed, raw)
|
|
122
108
|
if (ctx.request && targetId) {
|
|
123
109
|
await assertCanModifySuperAdminRole(ctx.request, targetId)
|
|
124
|
-
await assertCanAccessRoleTarget(ctx.request, targetId)
|
|
125
110
|
}
|
|
126
|
-
return parsed
|
|
111
|
+
return enforceRoleTenantAccess('delete', parsed, { auth: ctx.auth, container: ctx.container })
|
|
127
112
|
},
|
|
128
113
|
response: () => ({ ok: true }),
|
|
129
114
|
},
|
|
@@ -275,7 +260,6 @@ export const DELETE = async (req: Request) => {
|
|
|
275
260
|
if (targetId) {
|
|
276
261
|
try {
|
|
277
262
|
await assertCanModifySuperAdminRole(req, targetId)
|
|
278
|
-
await assertCanAccessRoleTarget(req, targetId)
|
|
279
263
|
} catch (err) {
|
|
280
264
|
if (err instanceof CrudHttpError) {
|
|
281
265
|
return NextResponse.json(err.body, { status: err.status })
|
|
@@ -301,21 +285,6 @@ async function assertCanModifySuperAdminRole(req: Request, targetRoleId: string)
|
|
|
301
285
|
})
|
|
302
286
|
}
|
|
303
287
|
|
|
304
|
-
async function assertCanAccessRoleTarget(req: Request, targetRoleId: string) {
|
|
305
|
-
const auth = await getAuthFromRequest(req)
|
|
306
|
-
if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
307
|
-
const container = await createRequestContainer()
|
|
308
|
-
const em = container.resolve('em') as EntityManager
|
|
309
|
-
await assertActorCanAccessRoleTarget({
|
|
310
|
-
em,
|
|
311
|
-
rbacService: container.resolve('rbacService') as RbacService,
|
|
312
|
-
actorUserId: auth.sub,
|
|
313
|
-
tenantId: auth.tenantId ?? null,
|
|
314
|
-
organizationId: auth.orgId ?? null,
|
|
315
|
-
targetRoleId,
|
|
316
|
-
})
|
|
317
|
-
}
|
|
318
|
-
|
|
319
288
|
function resolveDeleteTargetId(parsed: unknown, raw: unknown): string | null {
|
|
320
289
|
const fromParsed = readId(parsed as Record<string, unknown> | null | undefined)
|
|
321
290
|
if (fromParsed) return fromParsed
|
|
@@ -343,7 +312,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
343
312
|
},
|
|
344
313
|
POST: {
|
|
345
314
|
summary: 'Create role',
|
|
346
|
-
description: 'Creates a new role
|
|
315
|
+
description: 'Creates a new role anchored to the caller\'s tenant. Non-superadmins cannot target another tenant; supplying a foreign `tenantId` is rejected.',
|
|
347
316
|
requestBody: {
|
|
348
317
|
contentType: 'application/json',
|
|
349
318
|
schema: roleCreateSchema,
|
package/src/modules/auth/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
5
5
|
import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
6
6
|
import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
7
7
|
import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
|
|
8
|
-
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError } from './lib/setup-app'
|
|
8
|
+
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError, DerivedUserPasswordRequiredError } from './lib/setup-app'
|
|
9
9
|
import { normalizeTenantId } from './lib/tenantAccess'
|
|
10
10
|
import { computeEmailHash, emailHashLookupValues } from './lib/emailHash'
|
|
11
11
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
@@ -401,7 +401,7 @@ const addOrganization: ModuleCli = {
|
|
|
401
401
|
},
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--with-examples] [--json]'
|
|
404
|
+
const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--include-demo-users] [--with-examples] [--json]'
|
|
405
405
|
|
|
406
406
|
const setupApp: ModuleCli = {
|
|
407
407
|
command: 'setup',
|
|
@@ -440,6 +440,10 @@ const setupApp: ModuleCli = {
|
|
|
440
440
|
const withExamples = typeof withExamplesRaw === 'boolean'
|
|
441
441
|
? withExamplesRaw
|
|
442
442
|
: parseBooleanToken(typeof withExamplesRaw === 'string' ? withExamplesRaw : null) ?? false
|
|
443
|
+
const includeDemoUsersRaw = args['include-demo-users'] ?? args.includeDemoUsers
|
|
444
|
+
const includeDemoUsers = typeof includeDemoUsersRaw === 'boolean'
|
|
445
|
+
? includeDemoUsersRaw
|
|
446
|
+
: parseBooleanToken(typeof includeDemoUsersRaw === 'string' ? includeDemoUsersRaw : null) ?? false
|
|
443
447
|
const jsonModeRaw = args.json
|
|
444
448
|
const jsonMode = typeof jsonModeRaw === 'boolean'
|
|
445
449
|
? jsonModeRaw
|
|
@@ -489,7 +493,14 @@ const setupApp: ModuleCli = {
|
|
|
489
493
|
orgSlug,
|
|
490
494
|
roleNames,
|
|
491
495
|
primaryUser: { email, password, confirm: true },
|
|
492
|
-
|
|
496
|
+
// Derived admin/employee demo accounts only land when the operator
|
|
497
|
+
// explicitly opts in via --include-demo-users. Default-deny prevents
|
|
498
|
+
// production deployments from silently seeding well-known emails.
|
|
499
|
+
includeDerivedUsers: includeDemoUsers,
|
|
500
|
+
// When demo users are explicitly requested, the operator has consented
|
|
501
|
+
// to having admin@/employee@ accounts created with autogenerated
|
|
502
|
+
// passwords (surfaced in stdout) when no env override is provided.
|
|
503
|
+
allowDemoDerivedPasswords: includeDemoUsers,
|
|
493
504
|
// When the caller passes an explicit slug, treat it as a "fresh tenant"
|
|
494
505
|
// signal — silent reuse of an existing user's tenant defeats the point
|
|
495
506
|
// of slugging the new tenant for downstream tooling.
|
|
@@ -537,7 +548,12 @@ const setupApp: ModuleCli = {
|
|
|
537
548
|
if (env.NODE_ENV !== 'test') {
|
|
538
549
|
for (const snapshot of result.users) {
|
|
539
550
|
if (snapshot.created) {
|
|
540
|
-
|
|
551
|
+
if (snapshot.generatedPassword) {
|
|
552
|
+
console.log('⚠️ GENERATED password — copy now; it is not stored in plain text')
|
|
553
|
+
console.log('🎉 Created user', snapshot.user.email, 'password:', snapshot.generatedPassword)
|
|
554
|
+
} else {
|
|
555
|
+
console.log('🎉 Created user', snapshot.user.email)
|
|
556
|
+
}
|
|
541
557
|
} else {
|
|
542
558
|
console.log(`Updated user ${snapshot.user.email}`)
|
|
543
559
|
}
|
|
@@ -552,6 +568,13 @@ const setupApp: ModuleCli = {
|
|
|
552
568
|
process.exitCode = 1
|
|
553
569
|
return
|
|
554
570
|
}
|
|
571
|
+
if (err instanceof DerivedUserPasswordRequiredError) {
|
|
572
|
+
process.stderr.write(
|
|
573
|
+
`Setup aborted: ${err.message}. In production, set OM_INIT_ADMIN_PASSWORD and OM_INIT_EMPLOYEE_PASSWORD before passing --include-demo-users, or omit --include-demo-users to skip derived demo accounts entirely.\n`,
|
|
574
|
+
)
|
|
575
|
+
process.exitCode = 2
|
|
576
|
+
return
|
|
577
|
+
}
|
|
555
578
|
if (err instanceof Error && err.message === 'USER_EXISTS') {
|
|
556
579
|
process.stderr.write('Setup aborted: user already exists with the provided email.\n')
|
|
557
580
|
process.exitCode = 1
|