@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 0.10.1-develop.7749.1.ea3edf75e3
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/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
- package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
- package/dist/modules/attachments/api/file/[id]/route.js +16 -10
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +100 -56
- package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
- package/dist/modules/attachments/api/library/route.js +56 -17
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +2 -0
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +74 -34
- package/dist/modules/attachments/api/route.js.map +3 -3
- package/dist/modules/attachments/api/transfer/route.js +63 -37
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +41 -0
- package/dist/modules/attachments/data/validators.js.map +7 -0
- package/dist/modules/attachments/di.js +5 -2
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/generators.js +29 -0
- package/dist/modules/attachments/generators.js.map +7 -0
- package/dist/modules/attachments/index.js +8 -1
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-errors.js +35 -0
- package/dist/modules/attachments/lib/access-errors.js.map +7 -0
- package/dist/modules/attachments/lib/access-mutation.js +23 -0
- package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
- package/dist/modules/attachments/lib/access-protection.js +73 -0
- package/dist/modules/attachments/lib/access-protection.js.map +7 -0
- package/dist/modules/attachments/lib/access-query.js +74 -0
- package/dist/modules/attachments/lib/access-query.js.map +7 -0
- package/dist/modules/attachments/lib/access-registry.js +87 -0
- package/dist/modules/attachments/lib/access-registry.js.map +7 -0
- package/dist/modules/attachments/lib/access-runner.js +161 -0
- package/dist/modules/attachments/lib/access-runner.js.map +7 -0
- package/dist/modules/attachments/lib/access-types.js +1 -0
- package/dist/modules/attachments/lib/access-types.js.map +7 -0
- package/dist/modules/attachments/lib/attachment-service.js +14 -6
- package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
- package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
- package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
- package/dist/modules/attachments/lib/partitions.js +3 -2
- package/dist/modules/attachments/lib/partitions.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/target-access-service.js +12 -3
- package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
- package/dist/modules/attachments/setup.js +10 -0
- package/dist/modules/attachments/setup.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +8 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -0
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +1 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +45 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +27 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/dist/modules/staff/notifications.js +4 -2
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +18 -4
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-types.js +2 -2
- package/dist/modules/workflows/lib/activity-types.js.map +2 -2
- package/dist/modules/workflows/lib/condition-handler.js +7 -1
- package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +13 -0
- package/dist/modules/workflows/lib/duration.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-quick-action.js +13 -10
- package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- package/src/modules/attachments/api/file/[id]/route.ts +12 -9
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
- package/src/modules/attachments/api/library/[id]/route.ts +87 -58
- package/src/modules/attachments/api/library/route.ts +40 -11
- package/src/modules/attachments/api/partitions/route.ts +2 -0
- package/src/modules/attachments/api/route.ts +68 -35
- package/src/modules/attachments/api/transfer/route.ts +65 -42
- package/src/modules/attachments/data/entities.ts +4 -0
- package/src/modules/attachments/data/validators.ts +36 -0
- package/src/modules/attachments/di.ts +4 -1
- package/src/modules/attachments/generators.ts +27 -0
- package/src/modules/attachments/i18n/de.json +4 -0
- package/src/modules/attachments/i18n/en.json +4 -0
- package/src/modules/attachments/i18n/es.json +4 -0
- package/src/modules/attachments/i18n/ko.json +4 -0
- package/src/modules/attachments/i18n/pl.json +4 -0
- package/src/modules/attachments/index.ts +18 -0
- package/src/modules/attachments/lib/access-errors.ts +32 -0
- package/src/modules/attachments/lib/access-mutation.ts +26 -0
- package/src/modules/attachments/lib/access-protection.ts +83 -0
- package/src/modules/attachments/lib/access-query.ts +86 -0
- package/src/modules/attachments/lib/access-registry.ts +117 -0
- package/src/modules/attachments/lib/access-runner.ts +183 -0
- package/src/modules/attachments/lib/access-types.ts +78 -0
- package/src/modules/attachments/lib/attachment-service.ts +8 -4
- package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
- package/src/modules/attachments/lib/partitions.ts +3 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
- package/src/modules/attachments/lib/target-access-service.ts +10 -3
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
- package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
- package/src/modules/attachments/setup.ts +10 -0
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/notifications/api/[id]/action/route.ts +8 -0
- package/src/modules/notifications/api/route.ts +17 -0
- package/src/modules/notifications/data/validators.ts +1 -1
- package/src/modules/notifications/i18n/de.json +2 -0
- package/src/modules/notifications/i18n/en.json +2 -0
- package/src/modules/notifications/i18n/es.json +2 -0
- package/src/modules/notifications/i18n/ko.json +2 -0
- package/src/modules/notifications/i18n/pl.json +2 -0
- package/src/modules/notifications/lib/notificationService.ts +67 -1
- package/src/modules/notifications/lib/routeHelpers.ts +38 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
- package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
- package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
- package/src/modules/staff/notifications.ts +2 -0
- package/src/modules/workflows/AGENTS.md +2 -2
- package/src/modules/workflows/data/entities.ts +6 -0
- package/src/modules/workflows/lib/activity-executor.ts +36 -4
- package/src/modules/workflows/lib/activity-types.ts +2 -2
- package/src/modules/workflows/lib/condition-handler.ts +16 -1
- package/src/modules/workflows/lib/duration.ts +25 -0
- package/src/modules/workflows/lib/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { LockMode } from '@mikro-orm/core'
|
|
3
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import { Attachment } from '../../data/entities'
|
|
5
|
+
import { createAttachmentAccessContext } from '../../lib/access-runner'
|
|
6
|
+
import { assertAttachmentOwnerAccess } from '../../lib/access-query'
|
|
7
|
+
import { withAttachmentAccessErrors, throwAttachmentAccessError } from '../../lib/access-errors'
|
|
8
|
+
import { prepareAttachmentMutation } from '../../lib/access-mutation'
|
|
9
|
+
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
1
10
|
import { NextResponse } from 'next/server'
|
|
2
11
|
import { z } from 'zod'
|
|
3
12
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -22,60 +31,72 @@ export const metadata = {
|
|
|
22
31
|
POST: { requireAuth: true, requireFeatures: ['attachments.manage'] },
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
async function transferAttachments(req: Request) {
|
|
26
35
|
const auth = await getAuthFromRequest(req)
|
|
27
36
|
if (!auth || !auth.tenantId || !auth.orgId) {
|
|
28
37
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
29
38
|
}
|
|
30
|
-
const json = await req
|
|
39
|
+
const json = await readJsonSafe(req, null)
|
|
31
40
|
const parsed = transferSchema.safeParse(json)
|
|
32
41
|
if (!parsed.success) {
|
|
33
42
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
34
43
|
}
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
AttachmentEntity = mod.Attachment
|
|
42
|
-
} catch {
|
|
43
|
-
return NextResponse.json({ error: 'Attachment model missing' }, { status: 500 })
|
|
44
|
-
}
|
|
44
|
+
const container = await createRequestContainer()
|
|
45
|
+
const em = container.resolve<EntityManager>('em')
|
|
46
|
+
const guard = await prepareAttachmentMutation({ container, req, auth, operation: 'update', payload: parsed.data })
|
|
47
|
+
const effective = transferSchema.safeParse(guard.modifiedPayload ?? parsed.data)
|
|
48
|
+
if (!effective.success) return throwAttachmentAccessError(403)
|
|
49
|
+
const { attachmentIds, entityId, fromRecordId } = effective.data
|
|
45
50
|
const filters: Record<string, unknown> = {
|
|
46
|
-
id: { $in: attachmentIds },
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
organizationId: auth.orgId,
|
|
50
|
-
}
|
|
51
|
-
if (fromRecordId) {
|
|
52
|
-
filters.recordId = fromRecordId
|
|
53
|
-
}
|
|
54
|
-
const records = await em.find(AttachmentEntity, filters)
|
|
55
|
-
if (!records.length) {
|
|
56
|
-
return NextResponse.json({ error: 'Attachments not found' }, { status: 404 })
|
|
51
|
+
id: { $in: [...new Set(attachmentIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0) }, entityId,
|
|
52
|
+
tenantId: auth.tenantId, organizationId: auth.orgId,
|
|
53
|
+
...(fromRecordId ? { recordId: fromRecordId } : {}),
|
|
57
54
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
const accessContext = createAttachmentAccessContext(container)
|
|
56
|
+
const outcome = await em.transactional(async (tx) => {
|
|
57
|
+
const records = await findWithDecryption(tx, Attachment, filters, {
|
|
58
|
+
orderBy: { id: 'asc' }, lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
|
|
59
|
+
}, { tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
60
|
+
if (records.length !== new Set(attachmentIds).size) return throwAttachmentAccessError(404)
|
|
61
|
+
const changes = []
|
|
62
|
+
for (const record of records) {
|
|
63
|
+
const recordPayload = { ...effective.data, attachmentIds: [record.id] }
|
|
64
|
+
const recordGuard = await prepareAttachmentMutation({
|
|
65
|
+
container, req, auth, recordId: record.id, operation: 'update', payload: recordPayload,
|
|
66
|
+
})
|
|
67
|
+
const recordEffective = transferSchema.safeParse(recordGuard.modifiedPayload ?? recordPayload)
|
|
68
|
+
if (!recordEffective.success
|
|
69
|
+
|| recordEffective.data.attachmentIds.length !== 1
|
|
70
|
+
|| recordEffective.data.attachmentIds[0] !== record.id
|
|
71
|
+
|| recordEffective.data.entityId !== record.entityId
|
|
72
|
+
|| (recordEffective.data.fromRecordId && recordEffective.data.fromRecordId !== record.recordId)) {
|
|
73
|
+
return throwAttachmentAccessError(403)
|
|
74
|
+
}
|
|
75
|
+
const destinationId = recordEffective.data.toRecordId
|
|
76
|
+
const metadata = readAttachmentMetadata(record.storageMetadata)
|
|
77
|
+
const assignments = metadata.assignments?.map((assignment) => (
|
|
78
|
+
assignment.type === entityId && assignment.id === (fromRecordId ?? record.recordId)
|
|
79
|
+
? { ...assignment, id: destinationId } : assignment
|
|
80
|
+
)) ?? []
|
|
81
|
+
const storageMetadata = mergeAttachmentMetadata(record.storageMetadata, { assignments })
|
|
82
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record, action: 'reassign', persistProtection: true })
|
|
83
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, recordId: destinationId, storageMetadata }, action: 'reassign' })
|
|
84
|
+
changes.push({ record, storageMetadata, destinationId, guard: recordGuard })
|
|
85
|
+
}
|
|
86
|
+
for (const { record, storageMetadata, destinationId } of changes) {
|
|
87
|
+
record.recordId = destinationId
|
|
88
|
+
record.storageMetadata = storageMetadata
|
|
89
|
+
}
|
|
90
|
+
await tx.flush()
|
|
91
|
+
return { updated: records.length, guards: changes.map((change) => change.guard) }
|
|
92
|
+
})
|
|
93
|
+
await guard.runAfterSuccess()
|
|
94
|
+
for (const recordGuard of outcome.guards) await recordGuard.runAfterSuccess()
|
|
95
|
+
return NextResponse.json({ ok: true, updated: outcome.updated })
|
|
77
96
|
}
|
|
78
97
|
|
|
98
|
+
export const POST = withAttachmentAccessErrors(transferAttachments)
|
|
99
|
+
|
|
79
100
|
export const openApi: OpenApiRouteDoc = {
|
|
80
101
|
tag: attachmentsTag,
|
|
81
102
|
summary: 'Transfer attachments between records',
|
|
@@ -95,6 +116,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
95
116
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
96
117
|
{ status: 404, description: 'Attachments not found', schema: attachmentErrorSchema },
|
|
97
118
|
{ status: 500, description: 'Attachment model missing', schema: attachmentErrorSchema },
|
|
119
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
120
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
98
121
|
],
|
|
99
122
|
},
|
|
100
123
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BigIntType, OptionalProps } from '@mikro-orm/core'
|
|
2
2
|
import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
|
|
3
3
|
import { resolveDefaultAttachmentOcrEnabled } from '../lib/ocrConfig'
|
|
4
|
+
import type { AttachmentAccessRequirement } from '../lib/access-types'
|
|
4
5
|
|
|
5
6
|
@Entity({ tableName: 'attachment_partitions' })
|
|
6
7
|
@Unique({ name: 'attachment_partitions_code_unique', properties: ['code'] })
|
|
@@ -25,6 +26,9 @@ export class AttachmentPartition {
|
|
|
25
26
|
@Property({ name: 'config_json', type: 'json', nullable: true })
|
|
26
27
|
configJson?: Record<string, unknown> | null
|
|
27
28
|
|
|
29
|
+
@Property({ name: 'access_resolver_requirements', type: 'jsonb', nullable: true })
|
|
30
|
+
accessResolverRequirements?: AttachmentAccessRequirement[] | null
|
|
31
|
+
|
|
28
32
|
@Property({ name: 'is_public', type: 'boolean', default: false })
|
|
29
33
|
isPublic: boolean = false
|
|
30
34
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { AttachmentAccessResolver } from '../lib/access-types'
|
|
3
|
+
|
|
4
|
+
export const attachmentAccessActionSchema = z.enum(['read', 'render', 'metadata', 'reassign', 'delete', 'export'])
|
|
5
|
+
export const attachmentAccessSubjectFeaturesSchema = z.array(z.string())
|
|
6
|
+
export const attachmentAccessSelectorSchema = z.string().min(1).max(200).regex(/^(?:\*|[^*\s]+(?:\.\*)?)$/)
|
|
7
|
+
const resolverIdSchema = z.string().min(3).max(200).regex(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_.-]+$/)
|
|
8
|
+
|
|
9
|
+
export const attachmentAccessRequirementSchema = z.object({
|
|
10
|
+
resolverId: resolverIdSchema,
|
|
11
|
+
targetEntity: attachmentAccessSelectorSchema,
|
|
12
|
+
}).strict()
|
|
13
|
+
|
|
14
|
+
export const protectedAttachmentTargetSchema = attachmentAccessRequirementSchema.extend({
|
|
15
|
+
targetPartition: attachmentAccessSelectorSchema,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const attachmentAccessResolverSchema = z.object({
|
|
19
|
+
id: resolverIdSchema,
|
|
20
|
+
targetPartition: attachmentAccessSelectorSchema,
|
|
21
|
+
targetEntity: attachmentAccessSelectorSchema.optional(),
|
|
22
|
+
actions: z.array(attachmentAccessActionSchema).min(1).optional(),
|
|
23
|
+
priority: z.number().finite().optional(),
|
|
24
|
+
timeoutMs: z.number().int().min(1).max(15_000).optional(),
|
|
25
|
+
resolve: z.custom<AttachmentAccessResolver['resolve']>((value) => typeof value === 'function'),
|
|
26
|
+
}).strict()
|
|
27
|
+
|
|
28
|
+
export const attachmentAccessRequirementsSchema = z.array(attachmentAccessRequirementSchema)
|
|
29
|
+
export const attachmentAccessDecisionSchema = z.discriminatedUnion('ok', [
|
|
30
|
+
z.object({ ok: z.literal(true) }).strict(),
|
|
31
|
+
z.object({
|
|
32
|
+
ok: z.literal(false),
|
|
33
|
+
status: z.union([z.literal(401), z.literal(403), z.literal(404), z.literal(504)]),
|
|
34
|
+
reason: z.string().min(1).max(200).regex(/^[a-zA-Z0-9_.:-]+$/),
|
|
35
|
+
}).strict(),
|
|
36
|
+
])
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAttachmentAccessContext } from './lib/access-runner'
|
|
1
2
|
import { asFunction, asValue } from 'awilix'
|
|
2
3
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
4
|
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
@@ -9,6 +10,7 @@ import { AttachmentTargetAccessService } from './lib/target-access-service'
|
|
|
9
10
|
import { ScopedAttachmentUploadService } from './lib/scoped-upload-service'
|
|
10
11
|
|
|
11
12
|
export function register(container: AppContainer) {
|
|
13
|
+
const accessContext = () => createAttachmentAccessContext(container)
|
|
12
14
|
container.register({
|
|
13
15
|
attachmentQuotaRecoveryScheduler: asValue(scheduleAttachmentQuotaRecovery),
|
|
14
16
|
attachmentQuotaService: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
|
|
@@ -17,7 +19,7 @@ export function register(container: AppContainer) {
|
|
|
17
19
|
.scoped()
|
|
18
20
|
.proxy(),
|
|
19
21
|
attachmentTargetAccessService: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
|
|
20
|
-
new AttachmentTargetAccessService(em),
|
|
22
|
+
new AttachmentTargetAccessService(em, accessContext),
|
|
21
23
|
)
|
|
22
24
|
.scoped()
|
|
23
25
|
.proxy(),
|
|
@@ -59,6 +61,7 @@ export function register(container: AppContainer) {
|
|
|
59
61
|
cradle.em,
|
|
60
62
|
cradle.storageDriverFactory,
|
|
61
63
|
() => cradle.attachmentScopedUploadService ?? null,
|
|
64
|
+
accessContext,
|
|
62
65
|
))
|
|
63
66
|
.scoped()
|
|
64
67
|
.proxy(),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GeneratorPlugin } from '@open-mercato/shared/modules/generators'
|
|
2
|
+
|
|
3
|
+
const accessPlugin: GeneratorPlugin = {
|
|
4
|
+
id: 'attachments.access-resolvers',
|
|
5
|
+
conventionFile: 'data/attachment-access.ts',
|
|
6
|
+
importPrefix: 'ATTACHMENT_ACCESS',
|
|
7
|
+
configExpr: (importName, moduleId) =>
|
|
8
|
+
`{ moduleId: '${moduleId}', resolvers: (${importName}.attachmentAccessResolvers ?? []), protectedTargets: (${importName}.protectedAttachmentTargets ?? []) }`,
|
|
9
|
+
outputFileName: 'attachment-access.generated.ts',
|
|
10
|
+
buildOutput: ({ importSection, entriesLiteral }) => `// AUTO-GENERATED by mercato generate registry
|
|
11
|
+
import type { AttachmentAccessResolverEntry } from '@open-mercato/core/modules/attachments'
|
|
12
|
+
${importSection}
|
|
13
|
+
|
|
14
|
+
export const attachmentAccessEntries: AttachmentAccessResolverEntry[] = [
|
|
15
|
+
${entriesLiteral ? ` ${entriesLiteral}\n` : ''}]
|
|
16
|
+
`,
|
|
17
|
+
bootstrapRegistration: {
|
|
18
|
+
entriesExportName: 'attachmentAccessEntries',
|
|
19
|
+
registrationImports: [
|
|
20
|
+
"import { registerAttachmentAccessResolvers } from '@open-mercato/core/modules/attachments/lib/access-registry'",
|
|
21
|
+
],
|
|
22
|
+
buildCall: (entries) => `registerAttachmentAccessResolvers(${entries})`,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const generatorPlugins: GeneratorPlugin[] = [accessPlugin]
|
|
27
|
+
export default generatorPlugins
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
+
"attachments.access.forbidden": "Zugriff verweigert",
|
|
3
|
+
"attachments.access.notFound": "Anhang nicht gefunden.",
|
|
4
|
+
"attachments.access.timeout": "Zeitüberschreitung bei der Zugriffsprüfung des Anhangs. Bitte versuchen Sie es erneut.",
|
|
5
|
+
"attachments.access.unauthorized": "Nicht autorisiert",
|
|
2
6
|
"attachments.errors.activeContentBlocked": "Das Hochladen aktiver Inhalte ist nicht erlaubt.",
|
|
3
7
|
"attachments.errors.dangerousExecutable": "Ausführbare Dateitypen sind als Anhänge nicht erlaubt.",
|
|
4
8
|
"attachments.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
+
"attachments.access.forbidden": "Forbidden",
|
|
3
|
+
"attachments.access.notFound": "Attachment not found.",
|
|
4
|
+
"attachments.access.timeout": "Attachment authorization timed out. Please try again.",
|
|
5
|
+
"attachments.access.unauthorized": "Unauthorized",
|
|
2
6
|
"attachments.errors.activeContentBlocked": "Active content uploads are not allowed.",
|
|
3
7
|
"attachments.errors.dangerousExecutable": "Executable file types are not allowed as attachments.",
|
|
4
8
|
"attachments.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
+
"attachments.access.forbidden": "Acceso denegado",
|
|
3
|
+
"attachments.access.notFound": "No se encontró el archivo adjunto.",
|
|
4
|
+
"attachments.access.timeout": "Se agotó el tiempo de comprobación de acceso al archivo adjunto. Inténtalo de nuevo.",
|
|
5
|
+
"attachments.access.unauthorized": "No autorizado",
|
|
2
6
|
"attachments.errors.activeContentBlocked": "No se permite la carga de contenido activo.",
|
|
3
7
|
"attachments.errors.dangerousExecutable": "No se permiten archivos ejecutables como adjuntos.",
|
|
4
8
|
"attachments.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
+
"attachments.access.forbidden": "접근이 거부되었습니다",
|
|
3
|
+
"attachments.access.notFound": "첨부 파일을 찾을 수 없습니다.",
|
|
4
|
+
"attachments.access.timeout": "첨부 파일 접근 권한 확인 시간이 초과되었습니다. 다시 시도해 주세요.",
|
|
5
|
+
"attachments.access.unauthorized": "인증이 필요합니다",
|
|
2
6
|
"attachments.errors.activeContentBlocked": "활성 콘텐츠 업로드는 허용되지 않습니다.",
|
|
3
7
|
"attachments.errors.dangerousExecutable": "실행 파일 형식은 첨부 파일로 허용되지 않습니다.",
|
|
4
8
|
"attachments.errors.maxUploadSize": "첨부 파일이 최대 업로드 크기를 초과합니다.",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
+
"attachments.access.forbidden": "Brak dostępu",
|
|
3
|
+
"attachments.access.notFound": "Nie znaleziono załącznika.",
|
|
4
|
+
"attachments.access.timeout": "Przekroczono czas sprawdzania dostępu do załącznika. Spróbuj ponownie.",
|
|
5
|
+
"attachments.access.unauthorized": "Brak autoryzacji",
|
|
2
6
|
"attachments.errors.activeContentBlocked": "Przesyłanie treści aktywnych nie jest dozwolone.",
|
|
3
7
|
"attachments.errors.dangerousExecutable": "Pliki wykonywalne nie są dozwolone jako załączniki.",
|
|
4
8
|
"attachments.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
|
|
@@ -19,3 +19,21 @@ export {
|
|
|
19
19
|
type ReadScopedAttachmentResult,
|
|
20
20
|
type ReleaseScopedAttachmentInput,
|
|
21
21
|
} from './lib/attachment-service'
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
AttachmentAccessAction,
|
|
25
|
+
AttachmentAccessDecision,
|
|
26
|
+
AttachmentAccessInput,
|
|
27
|
+
AttachmentAccessPartition,
|
|
28
|
+
AttachmentAccessRecord,
|
|
29
|
+
AttachmentAccessRequirement,
|
|
30
|
+
AttachmentAccessResolver,
|
|
31
|
+
AttachmentAccessResolverEntry,
|
|
32
|
+
AttachmentAccessSubject,
|
|
33
|
+
AttachmentAccessTarget,
|
|
34
|
+
ProtectedAttachmentTarget,
|
|
35
|
+
} from './lib/access-types'
|
|
36
|
+
export { registerAttachmentAccessResolvers } from './lib/access-registry'
|
|
37
|
+
export { syncAttachmentAccessProtection } from './lib/access-protection'
|
|
38
|
+
export { createAttachmentAccessContext, evaluateAttachmentAccess } from './lib/access-runner'
|
|
39
|
+
export type { AttachmentAccessContext, AttachmentAccessResult } from './lib/access-runner'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
3
|
+
import type { AttachmentAccessDecision } from './access-types'
|
|
4
|
+
|
|
5
|
+
export async function attachmentAccessErrorBody(status: number): Promise<{ error: string }> {
|
|
6
|
+
const { t } = await resolveTranslations()
|
|
7
|
+
const key = status === 401 ? 'unauthorized' : status === 404 ? 'notFound' : status === 504 ? 'timeout' : 'forbidden'
|
|
8
|
+
return { error: t(`attachments.access.${key}`) }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function attachmentAccessErrorResponse(decision: Extract<AttachmentAccessDecision, { ok: false }>): Promise<Response> {
|
|
12
|
+
return Response.json(await attachmentAccessErrorBody(decision.status), {
|
|
13
|
+
status: decision.status, headers: { 'Cache-Control': 'private, no-store' },
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function throwAttachmentAccessError(status: number): Promise<never> {
|
|
18
|
+
throw new CrudHttpError(status, await attachmentAccessErrorBody(status))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withAttachmentAccessErrors<Arguments extends unknown[]>(handler: (...args: Arguments) => Promise<Response>) {
|
|
22
|
+
return async (...args: Arguments): Promise<Response> => {
|
|
23
|
+
try {
|
|
24
|
+
return await handler(...args)
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (isCrudHttpError(error)) {
|
|
27
|
+
return Response.json(error.body, { status: error.status, headers: { 'Cache-Control': 'private, no-store' } })
|
|
28
|
+
}
|
|
29
|
+
throw error
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { throwAttachmentAccessError } from './access-errors'
|
|
4
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
+
import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
|
|
6
|
+
|
|
7
|
+
export async function prepareAttachmentMutation(input: {
|
|
8
|
+
container: AwilixContainer
|
|
9
|
+
req: Request
|
|
10
|
+
auth: NonNullable<AuthContext>
|
|
11
|
+
recordId?: string
|
|
12
|
+
operation: 'update' | 'delete'
|
|
13
|
+
payload?: Record<string, unknown>
|
|
14
|
+
}) {
|
|
15
|
+
if (!input.auth.tenantId) return throwAttachmentAccessError(401)
|
|
16
|
+
const guard = await runRouteMutationGuards({
|
|
17
|
+
container: input.container, req: input.req,
|
|
18
|
+
auth: { tenantId: input.auth.tenantId, organizationId: input.auth.orgId ?? null, userId: input.auth.sub },
|
|
19
|
+
input: {
|
|
20
|
+
resourceKind: 'attachments:attachment', resourceId: input.recordId,
|
|
21
|
+
operation: input.operation, mutationPayload: input.payload,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
if (!guard.ok) throw new CrudHttpError(guard.errorStatus, guard.errorBody)
|
|
25
|
+
return guard
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import { AttachmentPartition } from '../data/entities'
|
|
5
|
+
import { attachmentAccessRequirementsSchema } from '../data/validators'
|
|
6
|
+
import { getAttachmentAccessRegistry, matchesAttachmentAccessSelector } from './access-registry'
|
|
7
|
+
import type { AttachmentAccessRequirement } from './access-types'
|
|
8
|
+
|
|
9
|
+
export function getDeclaredAttachmentAccessRequirements(partitionCode: string): AttachmentAccessRequirement[] {
|
|
10
|
+
const registry = getAttachmentAccessRegistry()
|
|
11
|
+
if (registry.invalid) throw new Error('[internal] Attachment access registry is invalid')
|
|
12
|
+
return registry.protectedTargets
|
|
13
|
+
.filter((target) => matchesAttachmentAccessSelector(target.targetPartition, partitionCode))
|
|
14
|
+
.map(({ resolverId, targetEntity }) => ({ resolverId, targetEntity }))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function mergeAttachmentAccessRequirements(
|
|
18
|
+
current: unknown,
|
|
19
|
+
required: readonly AttachmentAccessRequirement[],
|
|
20
|
+
): AttachmentAccessRequirement[] {
|
|
21
|
+
const existing = attachmentAccessRequirementsSchema.parse(current ?? [])
|
|
22
|
+
const union = new Map<string, AttachmentAccessRequirement>()
|
|
23
|
+
for (const requirement of [...existing, ...required]) {
|
|
24
|
+
union.set(JSON.stringify([requirement.resolverId, requirement.targetEntity]), { ...requirement })
|
|
25
|
+
}
|
|
26
|
+
return [...union.values()]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function syncAttachmentAccessProtection(
|
|
30
|
+
em: EntityManager,
|
|
31
|
+
options: { partitionCode?: string } = {},
|
|
32
|
+
): Promise<number> {
|
|
33
|
+
const registry = getAttachmentAccessRegistry()
|
|
34
|
+
if (registry.invalid) throw new Error('[internal] Attachment access registry is invalid')
|
|
35
|
+
if (!registry.protectedTargets.length) return 0
|
|
36
|
+
const isolated = em.fork({ clear: true, useContext: false })
|
|
37
|
+
const candidates = await findWithDecryption(
|
|
38
|
+
isolated,
|
|
39
|
+
AttachmentPartition,
|
|
40
|
+
options.partitionCode ? { code: options.partitionCode } : {},
|
|
41
|
+
{ orderBy: { id: 'asc' } },
|
|
42
|
+
{ tenantId: null, organizationId: null },
|
|
43
|
+
)
|
|
44
|
+
if (options.partitionCode && getDeclaredAttachmentAccessRequirements(options.partitionCode).length && !candidates.length) {
|
|
45
|
+
throw new Error('[internal] Protected attachment partition is not durably configured')
|
|
46
|
+
}
|
|
47
|
+
const pending = candidates.filter((partition) => {
|
|
48
|
+
const required = getDeclaredAttachmentAccessRequirements(partition.code)
|
|
49
|
+
if (!required.length) return false
|
|
50
|
+
const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
|
|
51
|
+
return JSON.stringify(mergeAttachmentAccessRequirements(existing, required)) !== JSON.stringify(existing)
|
|
52
|
+
})
|
|
53
|
+
if (!pending.length) return 0
|
|
54
|
+
return isolated.transactional(async (transaction) => {
|
|
55
|
+
let changed = 0
|
|
56
|
+
for (const candidate of pending) {
|
|
57
|
+
const partition = await findOneWithDecryption(transaction, AttachmentPartition, { id: candidate.id }, {
|
|
58
|
+
lockMode: LockMode.PESSIMISTIC_WRITE,
|
|
59
|
+
refresh: true,
|
|
60
|
+
}, { tenantId: null, organizationId: null })
|
|
61
|
+
if (!partition) throw new Error('[internal] Attachment partition disappeared during access protection synchronization')
|
|
62
|
+
const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
|
|
63
|
+
const merged = mergeAttachmentAccessRequirements(existing, getDeclaredAttachmentAccessRequirements(partition.code))
|
|
64
|
+
if (JSON.stringify(merged) === JSON.stringify(existing)) continue
|
|
65
|
+
partition.accessResolverRequirements = merged
|
|
66
|
+
changed += 1
|
|
67
|
+
}
|
|
68
|
+
if (changed) await transaction.flush()
|
|
69
|
+
return changed
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function ensureAttachmentPartitionProtection(
|
|
74
|
+
em: EntityManager,
|
|
75
|
+
partition: AttachmentPartition,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const required = getDeclaredAttachmentAccessRequirements(partition.code)
|
|
78
|
+
if (!required.length) return
|
|
79
|
+
const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
|
|
80
|
+
const merged = mergeAttachmentAccessRequirements(existing, required)
|
|
81
|
+
if (JSON.stringify(existing) === JSON.stringify(merged)) return
|
|
82
|
+
await syncAttachmentAccessProtection(em, { partitionCode: partition.code })
|
|
83
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ensureAttachmentPartitionProtection } from './access-protection'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { AttachmentPartition, type Attachment } from '../data/entities'
|
|
6
|
+
import { attachmentAccessRequirementsSchema } from '../data/validators'
|
|
7
|
+
import { getAttachmentAccessRegistry } from './access-registry'
|
|
8
|
+
import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'
|
|
9
|
+
import type { AttachmentAccessAction, AttachmentAccessRecord } from './access-types'
|
|
10
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
11
|
+
import { attachmentAccessErrorBody, throwAttachmentAccessError } from './access-errors'
|
|
12
|
+
import { readAttachmentMetadata } from './metadata'
|
|
13
|
+
|
|
14
|
+
export function requiresAttachmentAccessScan(partitions: readonly AttachmentPartition[]): boolean {
|
|
15
|
+
const registry = getAttachmentAccessRegistry()
|
|
16
|
+
return registry.invalid || registry.resolvers.length > 0 || registry.protectedTargets.length > 0
|
|
17
|
+
|| partitions.some((partition) => {
|
|
18
|
+
const parsed = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? [])
|
|
19
|
+
return !parsed.success || parsed.data.length > 0
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function assertAttachmentOwnerAccess(input: {
|
|
24
|
+
em: EntityManager
|
|
25
|
+
context: AttachmentAccessContext
|
|
26
|
+
auth: NonNullable<AuthContext>
|
|
27
|
+
attachment: AttachmentAccessRecord
|
|
28
|
+
action: AttachmentAccessAction
|
|
29
|
+
persistProtection?: boolean
|
|
30
|
+
}): Promise<void> {
|
|
31
|
+
const partition = await findOneWithDecryption(input.em, AttachmentPartition, { code: input.attachment.partitionCode }, undefined, {
|
|
32
|
+
tenantId: input.attachment.tenantId, organizationId: input.attachment.organizationId,
|
|
33
|
+
})
|
|
34
|
+
if (!partition) await throwAttachmentAccessError(404)
|
|
35
|
+
if (input.persistProtection) await ensureAttachmentPartitionProtection(input.em, partition!)
|
|
36
|
+
const result = await evaluateAttachmentAccess({ ...input, partition: partition!, requireAuthForPublic: true })
|
|
37
|
+
if (!result.ok) await throwAttachmentAccessError(result.status)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function scanAuthorizedAttachments(input: {
|
|
41
|
+
auth: NonNullable<AuthContext>
|
|
42
|
+
context: AttachmentAccessContext
|
|
43
|
+
partitions: readonly AttachmentPartition[]
|
|
44
|
+
loadBatch(offset: number, limit: number): Promise<Attachment[]>
|
|
45
|
+
offset?: number
|
|
46
|
+
limit?: number
|
|
47
|
+
collectTags?: boolean
|
|
48
|
+
deadline?: number
|
|
49
|
+
}): Promise<{ records: Attachment[]; total: number; tags: string[] }> {
|
|
50
|
+
const deadline = input.deadline ?? Date.now() + 15_000
|
|
51
|
+
const timeoutBody = await attachmentAccessErrorBody(504)
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
53
|
+
const scan = async () => {
|
|
54
|
+
const partitions = new Map(input.partitions.map((partition) => [partition.code, partition]))
|
|
55
|
+
const records: Attachment[] = []
|
|
56
|
+
const tags = new Set<string>()
|
|
57
|
+
let total = 0
|
|
58
|
+
for (let batchOffset = 0; ; batchOffset += 100) {
|
|
59
|
+
if (Date.now() >= deadline) await throwAttachmentAccessError(504)
|
|
60
|
+
const batch = await input.loadBatch(batchOffset, 100)
|
|
61
|
+
for (const attachment of batch) {
|
|
62
|
+
if (Date.now() >= deadline) await throwAttachmentAccessError(504)
|
|
63
|
+
const partition = partitions.get(attachment.partitionCode)
|
|
64
|
+
if (!partition) continue
|
|
65
|
+
const access = await evaluateAttachmentAccess({
|
|
66
|
+
auth: input.auth, attachment, partition, action: 'metadata', context: input.context,
|
|
67
|
+
requireAuthForPublic: true,
|
|
68
|
+
})
|
|
69
|
+
if (Date.now() >= deadline || (!access.ok && access.status === 504)) await throwAttachmentAccessError(504)
|
|
70
|
+
if (!access.ok) continue
|
|
71
|
+
if (input.collectTags) for (const tag of readAttachmentMetadata(attachment.storageMetadata).tags ?? []) tags.add(tag)
|
|
72
|
+
if (total >= (input.offset ?? 0) && (input.limit === undefined || records.length < input.limit)) records.push(attachment)
|
|
73
|
+
total += 1
|
|
74
|
+
}
|
|
75
|
+
if (batch.length < 100) break
|
|
76
|
+
}
|
|
77
|
+
return { records, total, tags: [...tags].sort((left, right) => left < right ? -1 : left > right ? 1 : 0) }
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return await Promise.race([scan(), new Promise<never>((_resolve, reject) => {
|
|
81
|
+
timer = setTimeout(() => reject(new CrudHttpError(504, timeoutBody)), Math.max(1, deadline - Date.now()))
|
|
82
|
+
})])
|
|
83
|
+
} finally {
|
|
84
|
+
if (timer) clearTimeout(timer)
|
|
85
|
+
}
|
|
86
|
+
}
|