@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.222
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/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +12 -4
- package/src/lib/server/host-visitor-records.js +20 -10
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +46 -33
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +5 -4
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +48 -9
- package/src/lib/server/web-risk.js +281 -143
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -1
- package/src/tenancy.js +5 -1
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type SiteIconSpec } from '@aglyn/aglyn/app-utils/site-icon-set';
|
|
18
|
+
import type { Sharp, SharpOptions } from 'sharp';
|
|
19
|
+
/**
|
|
20
|
+
* Draws one icon of a site's derived set (AGL-3484) — the pixels behind
|
|
21
|
+
* `?icon=` on the media CDN. The grammar of the set (which sizes, which
|
|
22
|
+
* plates, how each is addressed) is `site-icon-set.ts` in `@aglyn/aglyn`;
|
|
23
|
+
* this module only turns a source file and a spec into bytes.
|
|
24
|
+
*
|
|
25
|
+
* Every plate starts from the same step: the mark CENTER-FIT on a square,
|
|
26
|
+
* never stretched. A wordmark uploaded as an icon comes out letterboxed — the
|
|
27
|
+
* same artwork, smaller — rather than squashed into a square it never was.
|
|
28
|
+
*/
|
|
29
|
+
type SharpFactory = (input?: Buffer | SharpOptions, options?: SharpOptions) => Sharp;
|
|
30
|
+
export interface RenderedSiteIcon {
|
|
31
|
+
body: Buffer;
|
|
32
|
+
contentType: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Packs PNG images into one ICO container (PNG-in-ICO, which every browser
|
|
36
|
+
* since Windows Vista's era reads).
|
|
37
|
+
*
|
|
38
|
+
* The format is a 6-byte header, a 16-byte directory entry per image and the
|
|
39
|
+
* images themselves. A width or height of 256 is written as 0, per the format;
|
|
40
|
+
* nothing in {@link FAVICON_ICO_SIZES} reaches it, but a caller might.
|
|
41
|
+
*/
|
|
42
|
+
export declare function encodeIco(images: ReadonlyArray<{
|
|
43
|
+
size: number;
|
|
44
|
+
png: Buffer;
|
|
45
|
+
}>): Buffer;
|
|
46
|
+
/**
|
|
47
|
+
* One derived icon of `source`.
|
|
48
|
+
*
|
|
49
|
+
* - `transparent` — center-fit on a transparent square.
|
|
50
|
+
* - `flat` — center-fit, then flattened onto `background`: iOS renders a
|
|
51
|
+
* touch icon's transparency black, so a transparent mark would sit in a
|
|
52
|
+
* black tile on every home screen.
|
|
53
|
+
* - `maskable` — fitted inside the safe zone ({@link maskableMarkSize}) on a
|
|
54
|
+
* `background` plate, so a launcher's circle or squircle mask crops plate
|
|
55
|
+
* and never mark.
|
|
56
|
+
* - `ico` — the {@link FAVICON_ICO_SIZES} transparent PNGs in one ICO.
|
|
57
|
+
*
|
|
58
|
+
* `background` is anything {@link normalizeSiteIconBackground} reads, and
|
|
59
|
+
* white when it reads nothing. `sharp` is injectable for the module-shape
|
|
60
|
+
* reasons `loadSharp` documents; it is loaded when omitted.
|
|
61
|
+
*/
|
|
62
|
+
export declare function renderSiteIcon(options: {
|
|
63
|
+
source: Buffer;
|
|
64
|
+
spec: SiteIconSpec;
|
|
65
|
+
background?: string | null;
|
|
66
|
+
sharp?: SharpFactory;
|
|
67
|
+
}): Promise<RenderedSiteIcon>;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { DEFAULT_SITE_ICON_BACKGROUND, FAVICON_ICO_SIZES, maskableMarkSize, normalizeSiteIconBackground, siteIconContentType } from "@aglyn/aglyn/app-utils/site-icon-set";
|
|
18
|
+
import { loadSharp } from "./media-variants.js";
|
|
19
|
+
/** A source whose decoded pixels exceed this is refused rather than decoded. */ const SITE_ICON_MAX_INPUT_PIXELS = 50000000;
|
|
20
|
+
/** `sharp`'s supported SVG density range, kept well inside its upper bound. */ const SVG_DENSITY_RANGE = [
|
|
21
|
+
1,
|
|
22
|
+
4800
|
|
23
|
+
];
|
|
24
|
+
const TRANSPARENT = {
|
|
25
|
+
r: 0,
|
|
26
|
+
g: 0,
|
|
27
|
+
b: 0,
|
|
28
|
+
alpha: 0
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The input options that decode `source` sharply at `target` pixels.
|
|
32
|
+
*
|
|
33
|
+
* A raster decodes as it is. An SVG has no pixels until it is rasterized, and
|
|
34
|
+
* `sharp` does that at 72 dpi — a 24-unit icon would come out 24px and be
|
|
35
|
+
* UPSCALED to 512, which is the blur deriving was meant to avoid. So the
|
|
36
|
+
* density is raised until the longer side renders at least `target` pixels.
|
|
37
|
+
*/ async function decodeOptions(sharp, source, target) {
|
|
38
|
+
var _meta_width, _meta_height;
|
|
39
|
+
const base = {
|
|
40
|
+
limitInputPixels: SITE_ICON_MAX_INPUT_PIXELS
|
|
41
|
+
};
|
|
42
|
+
const meta = await sharp(source, base).metadata();
|
|
43
|
+
if (meta.format !== 'svg') return base;
|
|
44
|
+
const longest = Math.max((_meta_width = meta.width) != null ? _meta_width : 0, (_meta_height = meta.height) != null ? _meta_height : 0);
|
|
45
|
+
if (!longest) return base;
|
|
46
|
+
const density = Math.min(SVG_DENSITY_RANGE[1], Math.max(SVG_DENSITY_RANGE[0], Math.ceil(72 * target / longest)));
|
|
47
|
+
return _extends({}, base, {
|
|
48
|
+
density
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** The mark center-fit on a transparent `size` square, as PNG. */ async function containedMark(sharp, source, size) {
|
|
52
|
+
const options = await decodeOptions(sharp, source, size);
|
|
53
|
+
return sharp(source, options).resize(size, size, {
|
|
54
|
+
fit: 'contain',
|
|
55
|
+
background: TRANSPARENT
|
|
56
|
+
}).png().toBuffer();
|
|
57
|
+
}
|
|
58
|
+
/** `mark` (already `markSize` square) centered on an opaque `size` plate. */ async function onPlate(sharp, mark, size, background) {
|
|
59
|
+
const composed = await sharp({
|
|
60
|
+
create: {
|
|
61
|
+
width: size,
|
|
62
|
+
height: size,
|
|
63
|
+
channels: 4,
|
|
64
|
+
background: `#${background}`
|
|
65
|
+
}
|
|
66
|
+
}).composite([
|
|
67
|
+
{
|
|
68
|
+
input: mark,
|
|
69
|
+
gravity: 'center'
|
|
70
|
+
}
|
|
71
|
+
]).png().toBuffer();
|
|
72
|
+
// A second pass, because `sharp` flattens BEFORE it composites within one
|
|
73
|
+
// pipeline. The plate is opaque, and saying so in the file — no alpha
|
|
74
|
+
// channel at all — is what keeps iOS from painting anything black.
|
|
75
|
+
return sharp(composed).flatten({
|
|
76
|
+
background: `#${background}`
|
|
77
|
+
}).png().toBuffer();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Packs PNG images into one ICO container (PNG-in-ICO, which every browser
|
|
81
|
+
* since Windows Vista's era reads).
|
|
82
|
+
*
|
|
83
|
+
* The format is a 6-byte header, a 16-byte directory entry per image and the
|
|
84
|
+
* images themselves. A width or height of 256 is written as 0, per the format;
|
|
85
|
+
* nothing in {@link FAVICON_ICO_SIZES} reaches it, but a caller might.
|
|
86
|
+
*/ export function encodeIco(images) {
|
|
87
|
+
const header = Buffer.alloc(6);
|
|
88
|
+
header.writeUInt16LE(0, 0); // reserved
|
|
89
|
+
header.writeUInt16LE(1, 2); // type: icon
|
|
90
|
+
header.writeUInt16LE(images.length, 4);
|
|
91
|
+
const directory = Buffer.alloc(16 * images.length);
|
|
92
|
+
let offset = header.length + directory.length;
|
|
93
|
+
images.forEach(({ size, png }, index)=>{
|
|
94
|
+
const at = index * 16;
|
|
95
|
+
const edge = size >= 256 ? 0 : size;
|
|
96
|
+
directory.writeUInt8(edge, at); // width
|
|
97
|
+
directory.writeUInt8(edge, at + 1); // height
|
|
98
|
+
directory.writeUInt8(0, at + 2); // palette size
|
|
99
|
+
directory.writeUInt8(0, at + 3); // reserved
|
|
100
|
+
directory.writeUInt16LE(1, at + 4); // color planes
|
|
101
|
+
directory.writeUInt16LE(32, at + 6); // bits per pixel
|
|
102
|
+
directory.writeUInt32LE(png.length, at + 8);
|
|
103
|
+
directory.writeUInt32LE(offset, at + 12);
|
|
104
|
+
offset += png.length;
|
|
105
|
+
});
|
|
106
|
+
return Buffer.concat([
|
|
107
|
+
header,
|
|
108
|
+
directory,
|
|
109
|
+
...images.map(({ png })=>png)
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One derived icon of `source`.
|
|
114
|
+
*
|
|
115
|
+
* - `transparent` — center-fit on a transparent square.
|
|
116
|
+
* - `flat` — center-fit, then flattened onto `background`: iOS renders a
|
|
117
|
+
* touch icon's transparency black, so a transparent mark would sit in a
|
|
118
|
+
* black tile on every home screen.
|
|
119
|
+
* - `maskable` — fitted inside the safe zone ({@link maskableMarkSize}) on a
|
|
120
|
+
* `background` plate, so a launcher's circle or squircle mask crops plate
|
|
121
|
+
* and never mark.
|
|
122
|
+
* - `ico` — the {@link FAVICON_ICO_SIZES} transparent PNGs in one ICO.
|
|
123
|
+
*
|
|
124
|
+
* `background` is anything {@link normalizeSiteIconBackground} reads, and
|
|
125
|
+
* white when it reads nothing. `sharp` is injectable for the module-shape
|
|
126
|
+
* reasons `loadSharp` documents; it is loaded when omitted.
|
|
127
|
+
*/ export async function renderSiteIcon(options) {
|
|
128
|
+
var _options_sharp, _normalizeSiteIconBackground;
|
|
129
|
+
const { source, spec } = options;
|
|
130
|
+
const sharp = (_options_sharp = options.sharp) != null ? _options_sharp : await loadSharp();
|
|
131
|
+
const background = (_normalizeSiteIconBackground = normalizeSiteIconBackground(options.background)) != null ? _normalizeSiteIconBackground : DEFAULT_SITE_ICON_BACKGROUND;
|
|
132
|
+
const contentType = siteIconContentType(spec);
|
|
133
|
+
if (spec.plate === 'ico') {
|
|
134
|
+
const images = await Promise.all(FAVICON_ICO_SIZES.map(async (size)=>({
|
|
135
|
+
size,
|
|
136
|
+
png: await containedMark(sharp, source, size)
|
|
137
|
+
})));
|
|
138
|
+
return {
|
|
139
|
+
body: encodeIco(images),
|
|
140
|
+
contentType
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (spec.plate === 'transparent') {
|
|
144
|
+
return {
|
|
145
|
+
body: await containedMark(sharp, source, spec.size),
|
|
146
|
+
contentType
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const markSize = spec.plate === 'maskable' ? maskableMarkSize(spec.size) : spec.size;
|
|
150
|
+
const mark = await containedMark(sharp, source, markSize);
|
|
151
|
+
return {
|
|
152
|
+
body: await onPlate(sharp, mark, spec.size, background),
|
|
153
|
+
contentType
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
//# sourceMappingURL=site-icon-render.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/site-icon-render.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n DEFAULT_SITE_ICON_BACKGROUND,\n FAVICON_ICO_SIZES,\n maskableMarkSize,\n normalizeSiteIconBackground,\n siteIconContentType,\n type SiteIconSpec,\n} from '@aglyn/aglyn/app-utils/site-icon-set'\nimport type { Sharp, SharpOptions } from 'sharp'\n\nimport { loadSharp } from './media-variants'\n\n/**\n * Draws one icon of a site's derived set (AGL-3484) — the pixels behind\n * `?icon=` on the media CDN. The grammar of the set (which sizes, which\n * plates, how each is addressed) is `site-icon-set.ts` in `@aglyn/aglyn`;\n * this module only turns a source file and a spec into bytes.\n *\n * Every plate starts from the same step: the mark CENTER-FIT on a square,\n * never stretched. A wordmark uploaded as an icon comes out letterboxed — the\n * same artwork, smaller — rather than squashed into a square it never was.\n */\n\ntype SharpFactory = (input?: Buffer | SharpOptions, options?: SharpOptions) => Sharp\n\n/** A source whose decoded pixels exceed this is refused rather than decoded. */\nconst SITE_ICON_MAX_INPUT_PIXELS = 50_000_000\n\n/** `sharp`'s supported SVG density range, kept well inside its upper bound. */\nconst SVG_DENSITY_RANGE = [1, 4800] as const\n\nconst TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 }\n\nexport interface RenderedSiteIcon {\n body: Buffer\n contentType: string\n}\n\n/**\n * The input options that decode `source` sharply at `target` pixels.\n *\n * A raster decodes as it is. An SVG has no pixels until it is rasterized, and\n * `sharp` does that at 72 dpi — a 24-unit icon would come out 24px and be\n * UPSCALED to 512, which is the blur deriving was meant to avoid. So the\n * density is raised until the longer side renders at least `target` pixels.\n */\nasync function decodeOptions(\n sharp: SharpFactory,\n source: Buffer,\n target: number,\n): Promise<SharpOptions> {\n const base: SharpOptions = { limitInputPixels: SITE_ICON_MAX_INPUT_PIXELS }\n const meta = await sharp(source, base).metadata()\n if (meta.format !== 'svg') return base\n const longest = Math.max(meta.width ?? 0, meta.height ?? 0)\n if (!longest) return base\n const density = Math.min(\n SVG_DENSITY_RANGE[1],\n Math.max(SVG_DENSITY_RANGE[0], Math.ceil((72 * target) / longest)),\n )\n return { ...base, density }\n}\n\n/** The mark center-fit on a transparent `size` square, as PNG. */\nasync function containedMark(\n sharp: SharpFactory,\n source: Buffer,\n size: number,\n): Promise<Buffer> {\n const options = await decodeOptions(sharp, source, size)\n return sharp(source, options)\n .resize(size, size, { fit: 'contain', background: TRANSPARENT })\n .png()\n .toBuffer()\n}\n\n/** `mark` (already `markSize` square) centered on an opaque `size` plate. */\nasync function onPlate(\n sharp: SharpFactory,\n mark: Buffer,\n size: number,\n background: string,\n): Promise<Buffer> {\n const composed = await sharp({\n create: {\n width: size,\n height: size,\n channels: 4,\n background: `#${background}`,\n },\n })\n .composite([{ input: mark, gravity: 'center' }])\n .png()\n .toBuffer()\n // A second pass, because `sharp` flattens BEFORE it composites within one\n // pipeline. The plate is opaque, and saying so in the file — no alpha\n // channel at all — is what keeps iOS from painting anything black.\n return sharp(composed)\n .flatten({ background: `#${background}` })\n .png()\n .toBuffer()\n}\n\n/**\n * Packs PNG images into one ICO container (PNG-in-ICO, which every browser\n * since Windows Vista's era reads).\n *\n * The format is a 6-byte header, a 16-byte directory entry per image and the\n * images themselves. A width or height of 256 is written as 0, per the format;\n * nothing in {@link FAVICON_ICO_SIZES} reaches it, but a caller might.\n */\nexport function encodeIco(\n images: ReadonlyArray<{ size: number; png: Buffer }>,\n): Buffer {\n const header = Buffer.alloc(6)\n header.writeUInt16LE(0, 0) // reserved\n header.writeUInt16LE(1, 2) // type: icon\n header.writeUInt16LE(images.length, 4)\n const directory = Buffer.alloc(16 * images.length)\n let offset = header.length + directory.length\n images.forEach(({ size, png }, index) => {\n const at = index * 16\n const edge = size >= 256 ? 0 : size\n directory.writeUInt8(edge, at) // width\n directory.writeUInt8(edge, at + 1) // height\n directory.writeUInt8(0, at + 2) // palette size\n directory.writeUInt8(0, at + 3) // reserved\n directory.writeUInt16LE(1, at + 4) // color planes\n directory.writeUInt16LE(32, at + 6) // bits per pixel\n directory.writeUInt32LE(png.length, at + 8)\n directory.writeUInt32LE(offset, at + 12)\n offset += png.length\n })\n return Buffer.concat([header, directory, ...images.map(({ png }) => png)])\n}\n\n/**\n * One derived icon of `source`.\n *\n * - `transparent` — center-fit on a transparent square.\n * - `flat` — center-fit, then flattened onto `background`: iOS renders a\n * touch icon's transparency black, so a transparent mark would sit in a\n * black tile on every home screen.\n * - `maskable` — fitted inside the safe zone ({@link maskableMarkSize}) on a\n * `background` plate, so a launcher's circle or squircle mask crops plate\n * and never mark.\n * - `ico` — the {@link FAVICON_ICO_SIZES} transparent PNGs in one ICO.\n *\n * `background` is anything {@link normalizeSiteIconBackground} reads, and\n * white when it reads nothing. `sharp` is injectable for the module-shape\n * reasons `loadSharp` documents; it is loaded when omitted.\n */\nexport async function renderSiteIcon(options: {\n source: Buffer\n spec: SiteIconSpec\n background?: string | null\n sharp?: SharpFactory\n}): Promise<RenderedSiteIcon> {\n const { source, spec } = options\n const sharp =\n options.sharp ?? ((await loadSharp()) as unknown as SharpFactory)\n const background =\n normalizeSiteIconBackground(options.background) ??\n DEFAULT_SITE_ICON_BACKGROUND\n const contentType = siteIconContentType(spec)\n\n if (spec.plate === 'ico') {\n const images = await Promise.all(\n FAVICON_ICO_SIZES.map(async (size) => ({\n size,\n png: await containedMark(sharp, source, size),\n })),\n )\n return { body: encodeIco(images), contentType }\n }\n if (spec.plate === 'transparent') {\n return { body: await containedMark(sharp, source, spec.size), contentType }\n }\n const markSize =\n spec.plate === 'maskable' ? maskableMarkSize(spec.size) : spec.size\n const mark = await containedMark(sharp, source, markSize)\n return {\n body: await onPlate(sharp, mark, spec.size, background),\n contentType,\n }\n}\n"],"names":["DEFAULT_SITE_ICON_BACKGROUND","FAVICON_ICO_SIZES","maskableMarkSize","normalizeSiteIconBackground","siteIconContentType","loadSharp","SITE_ICON_MAX_INPUT_PIXELS","SVG_DENSITY_RANGE","TRANSPARENT","r","g","b","alpha","decodeOptions","sharp","source","target","meta","base","limitInputPixels","metadata","format","longest","Math","max","width","height","density","min","ceil","containedMark","size","options","resize","fit","background","png","toBuffer","onPlate","mark","composed","create","channels","composite","input","gravity","flatten","encodeIco","images","header","Buffer","alloc","writeUInt16LE","length","directory","offset","forEach","index","at","edge","writeUInt8","writeUInt32LE","concat","map","renderSiteIcon","spec","contentType","plate","Promise","all","body","markSize"],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** Whether an auth record's claims carry `staff: true`. */
|
|
18
|
+
export declare function claimsHoldStaff(record: {
|
|
19
|
+
customClaims?: Record<string, unknown> | null;
|
|
20
|
+
} | null | undefined): boolean;
|
|
21
|
+
/** Whether the account `uid` holds the `staff` claim right now. */
|
|
22
|
+
export declare function isStaffAccount(uid: string): Promise<boolean>;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the account holding `email` holds the `staff` claim right now.
|
|
25
|
+
*
|
|
26
|
+
* Used when an INVITE is sent, before anyone has accepted it, so the invite
|
|
27
|
+
* reserves no seat. It is not trusted at acceptance: the account that accepts
|
|
28
|
+
* is checked again by uid, so stamping an invite to a staff address can never
|
|
29
|
+
* become a free seat for whoever else ends up accepting it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isStaffAddress(email: string): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Take the staff stamp off every row and pending invite an account holds,
|
|
34
|
+
* for when its `staff` claim is revoked.
|
|
35
|
+
*
|
|
36
|
+
* The rows come from the `users/{uid}/orgs` reverse index, never a
|
|
37
|
+
* collection-group scan of every workspace's roster. Invites are found by
|
|
38
|
+
* address, because they have no uid until accepted.
|
|
39
|
+
*
|
|
40
|
+
* It frees no access and adds no check: a former staff member who stays on
|
|
41
|
+
* a roster now takes a seat like anyone else, and a workspace that is over
|
|
42
|
+
* its cap because of it keeps everyone it has. Seat caps bind admission,
|
|
43
|
+
* never access.
|
|
44
|
+
*
|
|
45
|
+
* @returns how many rows and invites were unstamped, for the audit row.
|
|
46
|
+
*/
|
|
47
|
+
export declare function clearStaffSeatStamps(uid: string, email?: string | null, db?: FirebaseFirestore.Firestore): Promise<{
|
|
48
|
+
members: number;
|
|
49
|
+
invites: number;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* Platform staff take none of a customer's seats (AGL-3466).
|
|
18
|
+
*
|
|
19
|
+
* A roster row or invite for an account holding the `staff` claim is stamped
|
|
20
|
+
* `staffSeat: true` when the server writes it, and every seat counter skips a
|
|
21
|
+
* stamped entry (`usesCustomerSeat`). This module answers the two questions
|
|
22
|
+
* the writers ask — is this account staff, is this address staff's — and
|
|
23
|
+
* takes the stamp off again when the claim is revoked.
|
|
24
|
+
*
|
|
25
|
+
* Every lookup FAILS TOWARDS A SEAT. An auth lookup that throws, or an
|
|
26
|
+
* account that cannot be found, answers "not staff", so the row it decides
|
|
27
|
+
* takes a seat like any other. The opposite failure would hand a free seat to
|
|
28
|
+
* whoever the lookup could not see.
|
|
29
|
+
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
30
|
+
import { findUserByEmailAcrossPools, findUserByUidAcrossPools } from "./auth-pools.js";
|
|
31
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
32
|
+
/** Firestore's hard cap on writes in one batched commit. */ const FIRESTORE_BATCH_LIMIT = 500;
|
|
33
|
+
/** Whether an auth record's claims carry `staff: true`. */ export function claimsHoldStaff(record) {
|
|
34
|
+
var _record_customClaims;
|
|
35
|
+
return (record == null ? void 0 : (_record_customClaims = record.customClaims) == null ? void 0 : _record_customClaims['staff']) === true;
|
|
36
|
+
}
|
|
37
|
+
/** Whether the account `uid` holds the `staff` claim right now. */ export async function isStaffAccount(uid) {
|
|
38
|
+
if (!uid) return false;
|
|
39
|
+
try {
|
|
40
|
+
const found = await findUserByUidAcrossPools(uid);
|
|
41
|
+
return claimsHoldStaff(found == null ? void 0 : found.record);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('[staff-seat] staff lookup by uid failed', error);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the account holding `email` holds the `staff` claim right now.
|
|
49
|
+
*
|
|
50
|
+
* Used when an INVITE is sent, before anyone has accepted it, so the invite
|
|
51
|
+
* reserves no seat. It is not trusted at acceptance: the account that accepts
|
|
52
|
+
* is checked again by uid, so stamping an invite to a staff address can never
|
|
53
|
+
* become a free seat for whoever else ends up accepting it.
|
|
54
|
+
*/ export async function isStaffAddress(email) {
|
|
55
|
+
const address = typeof email === 'string' ? email.trim().toLowerCase() : '';
|
|
56
|
+
if (!address) return false;
|
|
57
|
+
try {
|
|
58
|
+
const found = await findUserByEmailAcrossPools(address);
|
|
59
|
+
return claimsHoldStaff(found == null ? void 0 : found.record);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('[staff-seat] staff lookup by email failed', error);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Take the staff stamp off every row and pending invite an account holds,
|
|
67
|
+
* for when its `staff` claim is revoked.
|
|
68
|
+
*
|
|
69
|
+
* The rows come from the `users/{uid}/orgs` reverse index, never a
|
|
70
|
+
* collection-group scan of every workspace's roster. Invites are found by
|
|
71
|
+
* address, because they have no uid until accepted.
|
|
72
|
+
*
|
|
73
|
+
* It frees no access and adds no check: a former staff member who stays on
|
|
74
|
+
* a roster now takes a seat like anyone else, and a workspace that is over
|
|
75
|
+
* its cap because of it keeps everyone it has. Seat caps bind admission,
|
|
76
|
+
* never access.
|
|
77
|
+
*
|
|
78
|
+
* @returns how many rows and invites were unstamped, for the audit row.
|
|
79
|
+
*/ export async function clearStaffSeatStamps(uid, email, db = firebaseAdmin.app().firestore()) {
|
|
80
|
+
const refs = [];
|
|
81
|
+
let members = 0;
|
|
82
|
+
let invites = 0;
|
|
83
|
+
if (uid) {
|
|
84
|
+
const memberships = await db.collection('users').doc(uid).collection('orgs').get();
|
|
85
|
+
const rows = await Promise.all(memberships.docs.map((membership)=>db.collection('orgs').doc(membership.id).collection('members').doc(uid).get()));
|
|
86
|
+
for (const row of rows){
|
|
87
|
+
if (row.exists && row.get('staffSeat') !== undefined) {
|
|
88
|
+
refs.push(row.ref);
|
|
89
|
+
members += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const address = typeof email === 'string' ? email.trim().toLowerCase() : '';
|
|
94
|
+
if (address) {
|
|
95
|
+
const pending = await db.collectionGroup('invites').where('email', '==', address).where('acceptedAt', '==', null).get();
|
|
96
|
+
for (const invite of pending.docs){
|
|
97
|
+
if (invite.get('staffSeat') !== undefined) {
|
|
98
|
+
refs.push(invite.ref);
|
|
99
|
+
invites += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for(let i = 0; i < refs.length; i += FIRESTORE_BATCH_LIMIT){
|
|
104
|
+
const batch = db.batch();
|
|
105
|
+
for (const ref of refs.slice(i, i + FIRESTORE_BATCH_LIMIT)){
|
|
106
|
+
batch.update(ref, {
|
|
107
|
+
staffSeat: FieldValue.delete()
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
await batch.commit();
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
members,
|
|
114
|
+
invites
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=staff-seat.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-seat.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Platform staff take none of a customer's seats (AGL-3466).\n *\n * A roster row or invite for an account holding the `staff` claim is stamped\n * `staffSeat: true` when the server writes it, and every seat counter skips a\n * stamped entry (`usesCustomerSeat`). This module answers the two questions\n * the writers ask — is this account staff, is this address staff's — and\n * takes the stamp off again when the claim is revoked.\n *\n * Every lookup FAILS TOWARDS A SEAT. An auth lookup that throws, or an\n * account that cannot be found, answers \"not staff\", so the row it decides\n * takes a seat like any other. The opposite failure would hand a free seat to\n * whoever the lookup could not see.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n findUserByEmailAcrossPools,\n findUserByUidAcrossPools,\n} from './auth-pools'\nimport firebaseAdmin from './firebase-admin'\n\n/** Firestore's hard cap on writes in one batched commit. */\nconst FIRESTORE_BATCH_LIMIT = 500\n\n/** Whether an auth record's claims carry `staff: true`. */\nexport function claimsHoldStaff(\n record: { customClaims?: Record<string, unknown> | null } | null | undefined,\n): boolean {\n return record?.customClaims?.['staff'] === true\n}\n\n/** Whether the account `uid` holds the `staff` claim right now. */\nexport async function isStaffAccount(uid: string): Promise<boolean> {\n if (!uid) return false\n try {\n const found = await findUserByUidAcrossPools(uid)\n return claimsHoldStaff(found?.record)\n } catch (error) {\n console.error('[staff-seat] staff lookup by uid failed', error)\n return false\n }\n}\n\n/**\n * Whether the account holding `email` holds the `staff` claim right now.\n *\n * Used when an INVITE is sent, before anyone has accepted it, so the invite\n * reserves no seat. It is not trusted at acceptance: the account that accepts\n * is checked again by uid, so stamping an invite to a staff address can never\n * become a free seat for whoever else ends up accepting it.\n */\nexport async function isStaffAddress(email: string): Promise<boolean> {\n const address = typeof email === 'string' ? email.trim().toLowerCase() : ''\n if (!address) return false\n try {\n const found = await findUserByEmailAcrossPools(address)\n return claimsHoldStaff(found?.record)\n } catch (error) {\n console.error('[staff-seat] staff lookup by email failed', error)\n return false\n }\n}\n\n/**\n * Take the staff stamp off every row and pending invite an account holds,\n * for when its `staff` claim is revoked.\n *\n * The rows come from the `users/{uid}/orgs` reverse index, never a\n * collection-group scan of every workspace's roster. Invites are found by\n * address, because they have no uid until accepted.\n *\n * It frees no access and adds no check: a former staff member who stays on\n * a roster now takes a seat like anyone else, and a workspace that is over\n * its cap because of it keeps everyone it has. Seat caps bind admission,\n * never access.\n *\n * @returns how many rows and invites were unstamped, for the audit row.\n */\nexport async function clearStaffSeatStamps(\n uid: string,\n email?: string | null,\n db: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<{ members: number; invites: number }> {\n const refs: FirebaseFirestore.DocumentReference[] = []\n let members = 0\n let invites = 0\n if (uid) {\n const memberships = await db.collection('users').doc(uid).collection('orgs').get()\n const rows = await Promise.all(\n memberships.docs.map((membership) =>\n db.collection('orgs').doc(membership.id).collection('members').doc(uid).get(),\n ),\n )\n for (const row of rows) {\n if (row.exists && row.get('staffSeat') !== undefined) {\n refs.push(row.ref)\n members += 1\n }\n }\n }\n const address = typeof email === 'string' ? email.trim().toLowerCase() : ''\n if (address) {\n const pending = await db\n .collectionGroup('invites')\n .where('email', '==', address)\n .where('acceptedAt', '==', null)\n .get()\n for (const invite of pending.docs) {\n if (invite.get('staffSeat') !== undefined) {\n refs.push(invite.ref)\n invites += 1\n }\n }\n }\n for (let i = 0; i < refs.length; i += FIRESTORE_BATCH_LIMIT) {\n const batch = db.batch()\n for (const ref of refs.slice(i, i + FIRESTORE_BATCH_LIMIT)) {\n batch.update(ref, { staffSeat: FieldValue.delete() })\n }\n await batch.commit()\n }\n return { members, invites }\n}\n"],"names":["FieldValue","findUserByEmailAcrossPools","findUserByUidAcrossPools","firebaseAdmin","FIRESTORE_BATCH_LIMIT","claimsHoldStaff","record","customClaims","isStaffAccount","uid","found","error","console","isStaffAddress","email","address","trim","toLowerCase","clearStaffSeatStamps","db","app","firestore","refs","members","invites","memberships","collection","doc","get","rows","Promise","all","docs","map","membership","id","row","exists","undefined","push","ref","pending","collectionGroup","where","invite","i","length","batch","slice","update","staffSeat","delete","commit"],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type TransferExportChoice, type TransferPrefs } from '@aglyn/aglyn/data-transfer';
|
|
18
|
+
import { type TransferEngineDeps } from './transfer-jobs';
|
|
19
|
+
export interface TransferExportInput extends TransferExportChoice {
|
|
20
|
+
orgId: string;
|
|
21
|
+
actorUid: string;
|
|
22
|
+
hostId?: string | null;
|
|
23
|
+
/** A scoped collaborator's tokens; absent for a reader who sees the whole workspace. */
|
|
24
|
+
scopeTokens?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
export interface TransferExportFile {
|
|
27
|
+
stream: ReadableStream<Uint8Array>;
|
|
28
|
+
/** The rows the file holds, when they could be counted before the first byte. */
|
|
29
|
+
rows: number | null;
|
|
30
|
+
fileName: string;
|
|
31
|
+
contentType: string;
|
|
32
|
+
/** The fields written, in order. */
|
|
33
|
+
fieldIds: string[];
|
|
34
|
+
/** The resource's name, for the audit and the file. */
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The export, ready to stream (see the block header): the resource
|
|
39
|
+
* resolved, the fields checked against its catalog in the person's order,
|
|
40
|
+
* the rows counted when they can be, and a stream that reads the rest page
|
|
41
|
+
* by page. A field the catalog no longer has, a format the resource does not
|
|
42
|
+
* write, or an empty selection is refused before a byte is sent.
|
|
43
|
+
*/
|
|
44
|
+
export declare function streamTransferExport(deps: Pick<TransferEngineDeps, 'resolveResource' | 'now'>, input: TransferExportInput): Promise<TransferExportFile>;
|
|
45
|
+
/** A person's remembered choices for one resource, as far as they can be trusted. */
|
|
46
|
+
export declare function readTransferPrefs(firestore: FirebaseFirestore.Firestore, uid: string, resource: string): Promise<TransferPrefs>;
|