@opensaas/stack-core 0.42.3 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,52 @@ import { applyCreateDefaults } from './apply-defaults.js'
35
35
  * persisted. See ADR-0010 for the full mechanism.
36
36
  */
37
37
 
38
+ /**
39
+ * Thrown when a non-sudo write's payload carries a nested `set`, `updateMany`
40
+ * or `deleteMany` under a relationship key (#1384). These three kinds were
41
+ * historically a pass-through straight to Prisma: the target list's
42
+ * `operation.update`/`operation.delete` access was never consulted, no hooks
43
+ * ran, and an unscoped `updateMany`/`deleteMany` `where` reached rows well
44
+ * outside the parent's own subtree. ADR-0050 removes nested relation input
45
+ * from the secured write surface entirely on the `prisma-8` line, so building
46
+ * per-kind access machinery for these three kinds on `main` would be work
47
+ * that major deletes wholesale — this refuses them instead, an interim
48
+ * fail-closed fix mirroring ADR-0050's own refusal shape.
49
+ *
50
+ * The refusal fires before any part of the write executes, so a payload
51
+ * mixing a refused kind with a permitted one persists nothing. `sudo()`
52
+ * still accepts all three kinds unchanged — the same escape hatch every
53
+ * other access-control refusal in this module leans on.
54
+ *
55
+ * Replacement: author the writes against the target list directly, via
56
+ * `context.db.<targetList>`, wrapped in `context.transaction` when they must
57
+ * land atomically with this write.
58
+ */
59
+ export class NestedRelationInputError extends Error {
60
+ public listKey: string
61
+ public fieldKey: string
62
+ public kinds: readonly string[]
63
+
64
+ constructor(listKey: string, fieldKey: string, kinds: readonly string[]) {
65
+ const kindList = kinds.map((kind) => `"${kind}"`).join(', ')
66
+ super(
67
+ `Cannot write "${listKey}.${fieldKey}" — this payload carries a nested ${kindList} ` +
68
+ `operation, which non-sudo contexts no longer accept (#1384): the target list's access ` +
69
+ `rules were never consulted for these kinds. Author the writes against the target list ` +
70
+ `directly (\`context.db.<targetList>\`), wrapped in \`context.transaction\` when they must ` +
71
+ `land atomically with this write, or use \`context.sudo()\` if this write is trusted to ` +
72
+ `bypass the target list's access entirely.`,
73
+ )
74
+ this.name = 'NestedRelationInputError'
75
+ this.listKey = listKey
76
+ this.fieldKey = fieldKey
77
+ this.kinds = kinds
78
+ }
79
+ }
80
+
81
+ /** Nested-op kinds refused outright for non-sudo contexts. See {@link NestedRelationInputError}. */
82
+ const REFUSED_KINDS = ['set', 'updateMany', 'deleteMany'] as const
83
+
38
84
  /** A deferred nested `afterOperation` task, run once the parent has persisted. */
39
85
  export interface AfterTask {
40
86
  /** Field name on the parent linking to the related list (for include lookup). */
@@ -371,20 +417,30 @@ async function processNestedCreate(
371
417
  }
372
418
 
373
419
  /**
374
- * Verify that a single connection target is reachable for the caller.
420
+ * Verify that a single connection (or, for `disconnect`, disconnection)
421
+ * target is reachable for the caller.
375
422
  *
376
- * Connecting references an existing row rather than modifying it, so — mirroring
377
- * Keystone — it requires **read/query** access on the target (#578), not update.
378
- * A filter-result query access is evaluated in the DATABASE via
379
- * `findFirst({ where: { AND: [connection, accessFilter] } })` rather than in
380
- * memory, so it correctly handles arbitrary nested-relation predicates and
381
- * boolean combinators; a non-existent id is folded into the same check.
423
+ * Both directions reference an existing row rather than modifying it, so —
424
+ * mirroring Keystone — they require **read/query** access on the target
425
+ * (#578/#1384), not update. A filter-result query access is evaluated in the
426
+ * DATABASE via `findFirst({ where: { AND: [connection, accessFilter] } })`
427
+ * rather than in memory, so it correctly handles arbitrary nested-relation
428
+ * predicates and boolean combinators; a non-existent id is folded into the
429
+ * same check.
382
430
  *
383
- * In ADDITION, the OWNING relationship field's field-level access (e.g.
384
- * `Post.author`'s `create`/`update` access) must permit the connect (#588) —
385
- * the other half Keystone requires: read access on the target AND write access
386
- * on the owning field. A deny here denies the connect even when the target row
387
- * is readable/reachable.
431
+ * For `connect` (the default, `action: 'connect'`), the OWNING relationship
432
+ * field's field-level access (e.g. `Post.author`'s `create`/`update` access)
433
+ * must ADDITIONALLY permit the connect (#588) the other half Keystone
434
+ * requires: read access on the target AND write access on the owning field. A
435
+ * deny here denies the connect even when the target row is readable/reachable.
436
+ *
437
+ * For `disconnect` (`action: 'disconnect'`), the caller passes
438
+ * `owningFieldAccess: undefined`, which makes this second gate a no-op
439
+ * (`checkFieldAccess` defaults an absent rule to allow) — deliberately: the
440
+ * owning field's write access is already enforced by Phase 5's
441
+ * `filterWritableFields` before nested-op processing ever sees this payload,
442
+ * so re-running it here would only duplicate that gate, not add one. See
443
+ * {@link processNestedDisconnect}.
388
444
  *
389
445
  * Sudo bypasses the entire check (handled by the caller).
390
446
  */
@@ -399,10 +455,15 @@ async function verifyConnectReachable(
399
455
  enclosingOperation: 'create' | 'update',
400
456
  enclosingItem: Record<string, unknown> | undefined,
401
457
  enclosingInputData: Record<string, unknown> | undefined,
458
+ action: 'connect' | 'disconnect' = 'connect',
402
459
  ): Promise<void> {
403
460
  // Access Prisma model dynamically - required because model names are generated at runtime
404
461
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
405
462
  const model = (prisma as any)[getDbKey(relatedListName)]
463
+ const deniedMessage =
464
+ action === 'connect'
465
+ ? 'Access denied: Cannot connect to this item'
466
+ : 'Access denied: Cannot disconnect from this item'
406
467
 
407
468
  // #588 owning-field gate (see docblock above). `item`/`inputData` are the
408
469
  // ENCLOSING write's `originalItem`/`inputData` — the same values the canonical
@@ -415,7 +476,7 @@ async function verifyConnectReachable(
415
476
  context,
416
477
  })
417
478
  if (!owningFieldAllowed) {
418
- throw new Error('Access denied: Cannot connect to this item')
479
+ throw new Error(deniedMessage)
419
480
  }
420
481
 
421
482
  const queryAccess = relatedListConfig.access?.operation?.query
@@ -425,14 +486,14 @@ async function verifyConnectReachable(
425
486
  })
426
487
 
427
488
  if (accessResult === false) {
428
- throw new Error('Access denied: Cannot connect to this item')
489
+ throw new Error(deniedMessage)
429
490
  }
430
491
 
431
492
  // Full access still verifies the row exists, to keep "Item not found" behaviour.
432
493
  if (accessResult === true) {
433
494
  const item = await model.findUnique({ where: connection })
434
495
  if (!item) {
435
- throw new Error(`Cannot connect: Item not found`)
496
+ throw new Error(`Cannot ${action}: Item not found`)
436
497
  }
437
498
  return
438
499
  }
@@ -444,7 +505,7 @@ async function verifyConnectReachable(
444
505
  })
445
506
 
446
507
  if (!reachable) {
447
- throw new Error('Access denied: Cannot connect to this item')
508
+ throw new Error(deniedMessage)
448
509
  }
449
510
  }
450
511
 
@@ -486,6 +547,56 @@ async function processNestedConnect(
486
547
  return connections
487
548
  }
488
549
 
550
+ /**
551
+ * Process a nested `disconnect` (#1384).
552
+ *
553
+ * `{ disconnect: true }` — the to-one boolean form — nulls the foreign key on
554
+ * the row already being updated; that row's own update access has already
555
+ * been checked (by the enclosing write), so it is left permitted, unchanged,
556
+ * with no target-list check. This is the form the admin UI's `removeRelated`
557
+ * action emits for a to-one back-reference (ADR-0018).
558
+ *
559
+ * `{ disconnect: { <criteria> } }` — or an array of criteria, for a to-many —
560
+ * names a target row on the OTHER list. This requires that target's
561
+ * `operation.query` access, Keystone's semantic, reusing `verifyConnectReachable`
562
+ * (see its docblock for why the owning-field gate is skipped here) rather than
563
+ * a second reachability check.
564
+ */
565
+ async function processNestedDisconnect(
566
+ value: unknown,
567
+ relatedListName: string,
568
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
569
+ relatedListConfig: ListConfig<any>,
570
+ context: StackContext,
571
+ prisma: unknown,
572
+ enclosingOperation: 'create' | 'update',
573
+ ): Promise<unknown> {
574
+ if (value === true || context._isSudo) {
575
+ return value
576
+ }
577
+
578
+ const criteriaArray = Array.isArray(value)
579
+ ? (value as Array<Record<string, unknown>>)
580
+ : [value as Record<string, unknown>]
581
+
582
+ for (const criteria of criteriaArray) {
583
+ await verifyConnectReachable(
584
+ criteria,
585
+ relatedListName,
586
+ relatedListConfig,
587
+ context,
588
+ prisma,
589
+ undefined,
590
+ enclosingOperation,
591
+ undefined,
592
+ undefined,
593
+ 'disconnect',
594
+ )
595
+ }
596
+
597
+ return value
598
+ }
599
+
489
600
  /**
490
601
  * Re-check a nested update/delete access result against the target row,
491
602
  * mirroring the Write Pipeline's `resolveExistingTarget` (#1081): `false`
@@ -1000,10 +1111,19 @@ interface NestedOpHandler {
1000
1111
  *
1001
1112
  * Kinds that run the full hook pipeline (`create`, `update`, `delete`, and the
1002
1113
  * create branch of `connectOrCreate`) run `beforeOperation` inline and register
1003
- * deferred `afterOperation` tasks. `connect`/`connectOrCreate`'s connect branch
1004
- * enforce access only. Remaining pass-through kinds (`disconnect`, `set`,
1005
- * `updateMany`, `deleteMany`) return their value unchanged so Prisma's own
1006
- * constraints apply — they are intentionally NOT in scope for #569.
1114
+ * deferred `afterOperation` tasks. `connect`/`connectOrCreate`'s connect branch,
1115
+ * and `disconnect`'s target-row form, enforce access only no hooks (#1384;
1116
+ * this settles the `disconnect` half of #569's original scope note, which is
1117
+ * now closed).
1118
+ *
1119
+ * `set`, `updateMany` and `deleteMany` are refused outright for non-sudo
1120
+ * contexts before dispatch even reaches this registry (see `REFUSED_KINDS` /
1121
+ * {@link NestedRelationInputError} near the top of this file) rather than
1122
+ * given their own access machinery — ADR-0050 removes nested relation input
1123
+ * from the secured write surface entirely on the `prisma-8` line, so that
1124
+ * machinery would be built into a module the next major deletes wholesale.
1125
+ * Their entries below therefore only ever run under `sudo()`, where they
1126
+ * remain an unchecked, hook-free pass-through to Prisma, unchanged.
1007
1127
  */
1008
1128
  const nestedOpRegistry: Record<string, NestedOpHandler> = {
1009
1129
  create: {
@@ -1125,9 +1245,22 @@ const nestedOpRegistry: Record<string, NestedOpHandler> = {
1125
1245
  afterTasks,
1126
1246
  ),
1127
1247
  },
1128
- // Pass-through kinds: no hooks/access control, left to Prisma's own constraints.
1129
- // (Out of scope for #569 see the issue's "Out of scope" notes.)
1130
- disconnect: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
1248
+ // Gated pass-through: no hooks, but the target-row form requires the target
1249
+ // list's operation.query access (#1384). See processNestedDisconnect.
1250
+ disconnect: {
1251
+ needsInclude: false,
1252
+ execute: ({ value, relatedListName, relatedListConfig, context, prisma, enclosingOperation }) =>
1253
+ processNestedDisconnect(
1254
+ value,
1255
+ relatedListName,
1256
+ relatedListConfig,
1257
+ context,
1258
+ prisma,
1259
+ enclosingOperation,
1260
+ ),
1261
+ },
1262
+ // Refused for non-sudo before dispatch reaches here (see REFUSED_KINDS) —
1263
+ // these entries only ever run under sudo, as an unchecked pass-through.
1131
1264
  deleteMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
1132
1265
  set: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
1133
1266
  updateMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
@@ -1161,6 +1294,18 @@ async function processFieldNestedOps(
1161
1294
  parentListName: string,
1162
1295
  parentOriginalItem: Record<string, unknown> | undefined,
1163
1296
  ): Promise<Record<string, unknown>> {
1297
+ // Refuse a non-sudo payload carrying a refused kind BEFORE any part of the
1298
+ // write executes — including before another kind on this same field
1299
+ // dispatches (see NestedRelationInputError's docblock). Collects every
1300
+ // refused kind present so a payload mixing e.g. `set` and `updateMany`
1301
+ // reports both in one error, not just the first found.
1302
+ if (!args.context._isSudo) {
1303
+ const refusedKinds = REFUSED_KINDS.filter((kind) => valueRecord[kind] !== undefined)
1304
+ if (refusedKinds.length > 0) {
1305
+ throw new NestedRelationInputError(parentListName, fieldName, refusedKinds)
1306
+ }
1307
+ }
1308
+
1164
1309
  const nestedOp: Record<string, unknown> = {}
1165
1310
 
1166
1311
  // Created-row recovery is only needed when this field has a creating kind
package/src/index.ts CHANGED
@@ -107,6 +107,14 @@ export { InvalidCreateAccessResultError } from './access/index.js'
107
107
  // validation failure.
108
108
  export { RelationFilterAccessDeniedError } from './access/index.js'
109
109
 
110
+ // Thrown by a non-sudo write whose payload carries a nested `set`,
111
+ // `updateMany` or `deleteMany` under a relationship key (see #1384). These
112
+ // three kinds were a pass-through straight to Prisma with no target-list
113
+ // access check and no hooks; `sudo()` still accepts them unchanged. Author
114
+ // the writes against the target list directly instead (`context.db.<list>`,
115
+ // wrapped in `context.transaction` when they must land atomically).
116
+ export { NestedRelationInputError } from './context/nested-operations.js'
117
+
110
118
  // Field self-containment validation — checks each field implements the
111
119
  // generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
112
120
  // getPrismaRelation for relationships) so a misimplemented field fails early
@@ -260,6 +260,11 @@ describe('getContext', () => {
260
260
  update: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
261
261
  delete: vi.fn(),
262
262
  },
263
+ // The disconnect target-row form now verifies the target is reachable
264
+ // under the caller's own query access (#1384) before disconnecting it.
265
+ teacher: {
266
+ findUnique: vi.fn().mockResolvedValue({ id: 't1', name: 'T' }),
267
+ },
263
268
  }
264
269
  const m2mConfig: OpenSaasConfig = {
265
270
  db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
@@ -2,14 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
  import { getContext } from '../src/context/index.js'
3
3
  import { config, list } from '../src/config/index.js'
4
4
  import { text, relationship } from '../src/fields/index.js'
5
+ import { NestedRelationInputError } from '../src/context/nested-operations.js'
5
6
 
6
7
  /**
7
8
  * These tests pin the behaviour of the nested-operation handler registry that
8
9
  * sits behind `processNestedOperations`. Each nested-op kind (create, connect,
9
- * connectOrCreate, update) plus the pass-through kinds (disconnect, delete,
10
- * deleteMany, set, updateMany) is dispatched via the registry. The tests assert
11
- * the exact payload handed to Prisma so a regression in dispatch/ordering is
12
- * caught.
10
+ * connectOrCreate, update, delete) plus `disconnect` (gated, #1384) is
11
+ * dispatched via the registry. `set`/`updateMany`/`deleteMany` are refused
12
+ * for non-sudo contexts (#1384) rather than dispatched at all. The tests
13
+ * assert the exact payload handed to Prisma so a regression in
14
+ * dispatch/ordering is caught.
13
15
  */
14
16
 
15
17
  function createMockPrisma() {
@@ -105,8 +107,8 @@ describe('Nested Operation Handler Registry', () => {
105
107
  mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original' })
106
108
  })
107
109
 
108
- describe('pass-through kinds', () => {
109
- it('passes disconnect through unchanged', async () => {
110
+ describe('disconnect (gated, #1384)', () => {
111
+ it('passes { disconnect: true } through unchanged with no target check', async () => {
110
112
  const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
111
113
 
112
114
  await context.db.post.update({
@@ -116,33 +118,47 @@ describe('Nested Operation Handler Registry', () => {
116
118
 
117
119
  const passedData = mockPrisma.post.update.mock.calls[0][0].data
118
120
  expect(passedData.author).toEqual({ disconnect: true })
121
+ expect(mockPrisma.user.findUnique).not.toHaveBeenCalled()
119
122
  })
120
123
 
121
- it('passes deleteMany, set and updateMany through unchanged', async () => {
122
- // NOTE (#569 / ADR-0010): nested `delete` is no longer a pass-through kind —
123
- // it now runs the full delete hook pipeline (access + before/afterOperation),
124
- // so it is tested separately below. `deleteMany`/`set`/`updateMany` remain
125
- // pass-through (out of scope for #569) and the payload is handed to Prisma
126
- // unchanged.
124
+ it('verifies target query access before passing a criteria-form disconnect through', async () => {
125
+ mockPrisma.tag.findUnique.mockResolvedValue({ id: 'old-tag', label: 'x' })
127
126
  const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
128
127
 
129
128
  await context.db.post.update({
130
129
  where: { id: '1' },
131
- data: {
132
- tags: {
133
- deleteMany: { label: { contains: 'x' } },
134
- set: [{ id: 'b' }],
135
- updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
136
- },
137
- },
130
+ data: { tags: { disconnect: { id: 'old-tag' } } },
138
131
  })
139
132
 
133
+ expect(mockPrisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: 'old-tag' } })
140
134
  const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
141
- expect(passedTags).toEqual({
142
- deleteMany: { label: { contains: 'x' } },
143
- set: [{ id: 'b' }],
144
- updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
135
+ expect(passedTags).toEqual({ disconnect: { id: 'old-tag' } })
136
+ })
137
+
138
+ it('denies a criteria-form disconnect naming a target the session cannot reach', async () => {
139
+ mockPrisma.tag.findUnique.mockResolvedValue(null)
140
+ const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
141
+
142
+ await expect(
143
+ context.db.post.update({
144
+ where: { id: '1' },
145
+ data: { tags: { disconnect: { id: 'missing-tag' } } },
146
+ }),
147
+ ).rejects.toThrow(/Cannot disconnect: Item not found/)
148
+ expect(mockPrisma.post.update).not.toHaveBeenCalled()
149
+ })
150
+
151
+ it('skips the target check under sudo, matching the historical pass-through', async () => {
152
+ const context = getContext(await buildConfig(), mockPrisma, { userId: '1' }).sudo()
153
+
154
+ await context.db.post.update({
155
+ where: { id: '1' },
156
+ data: { tags: { disconnect: { id: 'old-tag' } } },
145
157
  })
158
+
159
+ expect(mockPrisma.tag.findUnique).not.toHaveBeenCalled()
160
+ const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
161
+ expect(passedTags).toEqual({ disconnect: { id: 'old-tag' } })
146
162
  })
147
163
 
148
164
  it('runs the delete hook pipeline for nested delete then hands the payload to Prisma', async () => {
@@ -168,8 +184,78 @@ describe('Nested Operation Handler Registry', () => {
168
184
  })
169
185
  })
170
186
 
187
+ describe('refused kinds (set/updateMany/deleteMany, #1384)', () => {
188
+ it('refuses a non-sudo deleteMany/set/updateMany payload, naming the list, field and kinds', async () => {
189
+ const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
190
+
191
+ let caught: unknown
192
+ try {
193
+ await context.db.post.update({
194
+ where: { id: '1' },
195
+ data: {
196
+ tags: {
197
+ deleteMany: { label: { contains: 'x' } },
198
+ set: [{ id: 'b' }],
199
+ updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
200
+ },
201
+ },
202
+ })
203
+ } catch (err) {
204
+ caught = err
205
+ }
206
+
207
+ expect(caught).toBeInstanceOf(NestedRelationInputError)
208
+ const error = caught as NestedRelationInputError
209
+ expect(error.listKey).toBe('Post')
210
+ expect(error.fieldKey).toBe('tags')
211
+ expect(error.kinds).toEqual(['set', 'updateMany', 'deleteMany'])
212
+ // Nothing persisted — the refusal fires before the parent write executes.
213
+ expect(mockPrisma.post.update).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it('refuses even when the same payload also carries a permitted kind', async () => {
217
+ const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
218
+
219
+ await expect(
220
+ context.db.post.update({
221
+ where: { id: '1' },
222
+ data: {
223
+ tags: {
224
+ create: { label: 'new-tag' },
225
+ deleteMany: { label: { contains: 'x' } },
226
+ },
227
+ },
228
+ }),
229
+ ).rejects.toThrow(NestedRelationInputError)
230
+ expect(mockPrisma.post.update).not.toHaveBeenCalled()
231
+ })
232
+
233
+ it('still passes deleteMany, set and updateMany through unchanged under sudo', async () => {
234
+ const context = getContext(await buildConfig(), mockPrisma, { userId: '1' }).sudo()
235
+
236
+ await context.db.post.update({
237
+ where: { id: '1' },
238
+ data: {
239
+ tags: {
240
+ deleteMany: { label: { contains: 'x' } },
241
+ set: [{ id: 'b' }],
242
+ updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
243
+ },
244
+ },
245
+ })
246
+
247
+ const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
248
+ expect(passedTags).toEqual({
249
+ deleteMany: { label: { contains: 'x' } },
250
+ set: [{ id: 'b' }],
251
+ updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
252
+ })
253
+ })
254
+ })
255
+
171
256
  describe('multiple kinds on a single field', () => {
172
257
  it('dispatches create and disconnect together, preserving both', async () => {
258
+ mockPrisma.tag.findUnique.mockResolvedValue({ id: 'old-tag', label: 'x' })
173
259
  const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
174
260
 
175
261
  await context.db.post.update({
@@ -185,7 +271,7 @@ describe('Nested Operation Handler Registry', () => {
185
271
  const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
186
272
  // create is processed through hooks/access (object preserved)
187
273
  expect(passedTags.create).toEqual({ label: 'new-tag' })
188
- // disconnect is passed through untouched
274
+ // disconnect is passed through once its target is verified reachable
189
275
  expect(passedTags.disconnect).toEqual({ id: 'old-tag' })
190
276
  })
191
277
  })