|
68 | 68 |
|
69 | 69 | import { getMetadataTypeRedactor } from '@objectstack/spec/kernel'; |
70 | 70 | import type { MetadataTypeRedactor } from '@objectstack/spec/kernel'; |
| 71 | +// [#21120] The family-wide stored-metadata-body primitives — the object set, |
| 72 | +// the object predicate, the column names and the body redactor — live in |
| 73 | +// `@objectstack/spec/kernel`, reachable by every surface in the family |
| 74 | +// (service-analytics, plugin-audit, the objectql engine) that does not depend |
| 75 | +// on this package. The data-door wrappers below (`storedMetadataBodyProjection`, |
| 76 | +// `redactStoredMetadataRow`'s `dropType`, `storedMetadataBodyGroupingRefusal`) |
| 77 | +// are this package's own, built ON that one definition — never a second one. |
| 78 | +import { |
| 79 | + isStoredMetadataBodyObject, |
| 80 | + redactStoredMetadataBody, |
| 81 | + STORED_METADATA_BODY_COLUMN, |
| 82 | + STORED_METADATA_TYPE_COLUMN, |
| 83 | +} from '@objectstack/spec/kernel'; |
71 | 84 | import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; |
72 | 85 |
|
| 86 | +export { isStoredMetadataBodyObject }; |
| 87 | + |
73 | 88 | /** |
74 | 89 | * Resolve the redactor for a request-shaped type name. |
75 | 90 | * |
@@ -589,19 +604,14 @@ function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out: |
589 | 604 | * no write verb; `sys_metadata_history` is append-only), so a redacted body |
590 | 605 | * read here can never be PUT back — no carry-forward inverse is owed, which is |
591 | 606 | * what makes a pure read projection a complete answer on this door. |
| 607 | + * |
| 608 | + * [#21120] The object set, the `isStoredMetadataBodyObject` predicate and the |
| 609 | + * two column names are the family-wide definition in `@objectstack/spec/kernel`, |
| 610 | + * imported above and re-exported — one set, consumed by every surface, so the |
| 611 | + * audit, analytics and realtime exits cannot drift from this one. |
592 | 612 | */ |
593 | | -const STORED_METADATA_BODY_OBJECTS: ReadonlySet<string> = new Set(['sys_metadata', 'sys_metadata_history']); |
594 | | - |
595 | | -/** The column holding the serialized body, on every {@link STORED_METADATA_BODY_OBJECTS} member. */ |
596 | | -const STORED_BODY_COLUMN = 'metadata'; |
597 | | - |
598 | | -/** The column naming the body's metadata type — what selects its redactor. */ |
599 | | -const STORED_TYPE_COLUMN = 'type'; |
600 | | - |
601 | | -/** Whether `object`'s rows carry a stored metadata body the generic data door must project. */ |
602 | | -export function isStoredMetadataBodyObject(object: string): boolean { |
603 | | - return STORED_METADATA_BODY_OBJECTS.has(object); |
604 | | -} |
| 613 | +const STORED_BODY_COLUMN = STORED_METADATA_BODY_COLUMN; |
| 614 | +const STORED_TYPE_COLUMN = STORED_METADATA_TYPE_COLUMN; |
605 | 615 |
|
606 | 616 | /** |
607 | 617 | * The projection to hand the engine for a read of `object`, given the caller's |
@@ -655,31 +665,20 @@ export function redactStoredMetadataRow<T>(object: string, row: T, opts?: { drop |
655 | 665 | const { [STORED_TYPE_COLUMN]: _type, ...rest } = record; |
656 | 666 | return rest; |
657 | 667 | }; |
658 | | - const withheld = (): T => { |
| 668 | + |
| 669 | + // [#21120] The body decision is the ONE shared primitive — same object set, |
| 670 | + // same per-type redactor, same fail-closed rules — so this door cannot |
| 671 | + // disagree with the audit / analytics / realtime exits about what a |
| 672 | + // credential is. This function adds only the door-local wrinkles on top: the |
| 673 | + // `dropType` strip of the type column `storedMetadataBodyProjection` asked |
| 674 | + // for, and omitting the body on a fail-closed outcome. |
| 675 | + const outcome = redactStoredMetadataBody(row[STORED_TYPE_COLUMN], row[STORED_BODY_COLUMN]); |
| 676 | + if (!outcome.ok) { |
659 | 677 | const { [STORED_BODY_COLUMN]: _body, ...rest } = row; |
660 | 678 | return strip(rest) as T; |
661 | | - }; |
662 | | - |
663 | | - const body = row[STORED_BODY_COLUMN]; |
664 | | - if (body === undefined || body === null) return (dropType ? strip(row) : row) as T; |
665 | | - const type = row[STORED_TYPE_COLUMN]; |
666 | | - if (typeof type !== 'string' || type === '') return withheld(); |
667 | | - if (!hasMetadataRedactor(type)) return (dropType ? strip(row) : row) as T; |
668 | | - |
669 | | - let parsed: unknown = body; |
670 | | - if (typeof body === 'string') { |
671 | | - try { |
672 | | - parsed = JSON.parse(body); |
673 | | - } catch { |
674 | | - return withheld(); |
675 | | - } |
676 | 679 | } |
677 | | - const served = redactMetadataItem(type, parsed); |
678 | | - if (served === parsed) return (dropType ? strip(row) : row) as T; |
679 | | - return strip({ |
680 | | - ...row, |
681 | | - [STORED_BODY_COLUMN]: typeof body === 'string' ? JSON.stringify(served) : served, |
682 | | - }) as T; |
| 680 | + if (outcome.body === row[STORED_BODY_COLUMN]) return (dropType ? strip(row) : row) as T; |
| 681 | + return strip({ ...row, [STORED_BODY_COLUMN]: outcome.body }) as T; |
683 | 682 | } |
684 | 683 |
|
685 | 684 | /** {@link redactStoredMetadataRow} over the rows of one read. Non-array input passes through. */ |
@@ -727,3 +726,51 @@ export function storedMetadataBodyGroupingRefusal(object: string, groupBy: unkno |
727 | 726 | } |
728 | 727 | return undefined; |
729 | 728 | } |
| 729 | + |
| 730 | +/** |
| 731 | + * [#21120] The data door's FILTER / SORT refusal on the stored body column — |
| 732 | + * maintainer ruling A, the further accept-set narrowing the grouping refusal |
| 733 | + * (#21086) began. |
| 734 | + * |
| 735 | + * A filter on the body column EVALUATES the stored body row by row: a credential |
| 736 | + * withheld from every served answer is still recoverable by prefix probing |
| 737 | + * (`?filter={"metadata":{"$contains":"<guess>"}}` returns the row only when the |
| 738 | + * guess is a prefix — a predicate oracle). A sort on it orders by the same |
| 739 | + * stored bytes. Neither serves the body, so projecting it is no answer; the only |
| 740 | + * answer is to refuse, the same posture the engine's own masked-field guard and |
| 741 | + * the grouping refusal above take. Same family, shape and code: `INVALID_FIELD` |
| 742 | + * / 400, naming the field, the object and the offending `param`. |
| 743 | + * |
| 744 | + * `filterFields` is the set of head field names the caller's `where` names |
| 745 | + * (`collectFilterFieldKeys`), and `sortFields` the fields its `orderBy` names. |
| 746 | + * Filter is judged before sort — a query that does both reads "the filter was |
| 747 | + * not run" first. `undefined` when neither names the body column. |
| 748 | + */ |
| 749 | +export function storedMetadataBodyPredicateRefusal( |
| 750 | + object: string, |
| 751 | + opts: { filterFields?: readonly unknown[]; sortFields?: readonly unknown[] }, |
| 752 | +): Error | undefined { |
| 753 | + if (!isStoredMetadataBodyObject(object)) return undefined; |
| 754 | + const namesBody = (fields: readonly unknown[] | undefined): boolean => |
| 755 | + Array.isArray(fields) && fields.some((f) => f === STORED_BODY_COLUMN); |
| 756 | + const make = (param: 'filter' | 'sort', verb: string): Error => { |
| 757 | + const err: any = new Error( |
| 758 | + `Cannot ${verb} '${object}' by '${STORED_BODY_COLUMN}' (${param}): the query was not run. The ` |
| 759 | + + `${STORED_BODY_COLUMN} column holds a stored metadata body, served only as its type's read ` |
| 760 | + + `projection with stored credential material withheld. ${param === 'filter' |
| 761 | + ? 'A filter on it evaluates the stored body row by row, which rebuilds a withheld credential by probing' |
| 762 | + : 'A sort on it orders by the same stored bytes'}, so it is refused rather than evaluated. ` |
| 763 | + + `Filter or sort by '${STORED_TYPE_COLUMN}', 'name' or another scalar column instead.`, |
| 764 | + ); |
| 765 | + err.code = 'INVALID_FIELD'; |
| 766 | + err.status = 400; |
| 767 | + err.field = STORED_BODY_COLUMN; |
| 768 | + err.fields = [STORED_BODY_COLUMN]; |
| 769 | + err.object = object; |
| 770 | + err.param = param; |
| 771 | + return err; |
| 772 | + }; |
| 773 | + if (namesBody(opts.filterFields)) return make('filter', 'filter'); |
| 774 | + if (namesBody(opts.sortFields)) return make('sort', 'sort'); |
| 775 | + return undefined; |
| 776 | +} |
0 commit comments