@zeruxjs/tags 0.0.0-stage → 0.0.1
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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +9 -0
- package/README.md +110 -2
- package/dist/browser.d.ts +8 -0
- package/dist/browser.js +52 -0
- package/dist/container.d.ts +4 -0
- package/dist/container.js +96 -0
- package/dist/dashboard.d.ts +1 -0
- package/dist/dashboard.js +51 -0
- package/dist/governance.d.ts +20 -0
- package/dist/governance.js +62 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/runtime.d.ts +24 -0
- package/dist/runtime.js +357 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.js +1 -0
- package/package.json +34 -4
package/LICENSE-APACHE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/LICENSE-MIT
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZeruxJS.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zeruxjs/tags — server and browser tag manager
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Managed triggers, tags, data-layer variables, browser `ttag()` and server `astag()` APIs. Supports Zerux internal analytics, **server-side GA4 Measurement Protocol**, and preapproved HTTPS webhook destinations. Credentials are only resolved on the server. Does not execute arbitrary browser script tags.
|
|
4
|
+
|
|
5
|
+
## Zerux runtime configuration
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
export default {
|
|
9
|
+
analytics: { analytics: 'reporting', enabled: true },
|
|
10
|
+
tags: {
|
|
11
|
+
enabled: true,
|
|
12
|
+
site: 'storefront',
|
|
13
|
+
prefix: '/_tags', // browser: /_tags/ttag.js, collector: /_tags/collect
|
|
14
|
+
publicOrigin: 'https://tags.example.com', // optional for tagging subdomain
|
|
15
|
+
allowedOrigins: ['https://shop.example.com'],
|
|
16
|
+
dashboardHost: 'tags.example.com', // optional restrict dashboard host
|
|
17
|
+
authorizeAdmin: async req => await sessionHasRole(req, 'analytics_admin'),
|
|
18
|
+
credentials: { ga4: {secret: process.env.GA4_MP_SECRET!} },
|
|
19
|
+
destinations: { crm: { url: 'https://hooks.example.net/events', headers: { Authorization: `Bearer ${process.env.HOOK_TOKEN!}` } } },
|
|
20
|
+
debug: process.env.NODE_ENV !== 'production',
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The default container includes an **internal analytics tag** fired for all events. `dashboardHost` applies to admin routes only. Session/role authorization must be supplied by the host application; otherwise **no admin routes are registered**. Admin PUT requires a matching Origin and a monotonically increasing version. Credentials and destination URLs never appear in public container JSON. Direct browser-origin POSTs cannot claim `server` events.
|
|
26
|
+
|
|
27
|
+
For browser collection with cross-origin tags, put the script on the page:
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<script>window.dataLayer=window.dataLayer||[];window.dataLayer.push({event:'preload',properties:{section:'home'}});</script>
|
|
31
|
+
<script async src="https://tags.example.com/_tags/ttag.js"></script>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const requestId = ttag('event', 'purchase', { value: 49.99, currency: 'USD' });
|
|
36
|
+
ttag('set', 'sessionId', 'opaque-session'); // optional stable client_id for GA4
|
|
37
|
+
const x = ttag('get', 'sessionId');
|
|
38
|
+
// ttag('config', {...}) is intentionally disabled: tenant/debug are server-controlled.
|
|
39
|
+
ttag('consent', 'denied'); // telemetry-only switch, unrelated to email marketing consent
|
|
40
|
+
window.dataLayer.push({event:'cart_add',properties:{quantity:1}});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`ttag('event')` returns the **16-character request ID** immediately; that ID is also attached to the collected event and passed to matching triggers. Events queue asynchronously, attempt `navigator.sendBeacon()` first (CORS-simple text/plain), then `fetch(...,{keepalive:true})` if Beacon cannot queue. Flushing also occurs on `visibilitychange` / `pagehide`. Network delivery is **best-effort**, not an exactly-once guarantee. Browsers that do not provide secure `crypto.getRandomValues()` cannot safely generate IDs and therefore do not send telemetry.
|
|
44
|
+
|
|
45
|
+
On the server, get a request-scoped function (not a cross-request global singleton):
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const tagService = runtimeServices.get('tags');
|
|
49
|
+
const astag = tagService.createAstag({site:'storefront',sessionId:'opaque-user-id'});
|
|
50
|
+
await astag('set', 'tier', 'pro');
|
|
51
|
+
const result = await astag('event', 'checkout', {value:125});
|
|
52
|
+
await astag('get', 'tier');
|
|
53
|
+
await astag('config', {pathname:'/checkout'});
|
|
54
|
+
await astag('consent', 'denied'); // telemetry control only
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A server request scope can generate **one** request ID, reserved for the UTC day, and reuse it across related events; browser deliveries use **one ID per POST**. The current UTC day is assigned on the server. The event row contains reserved `zRequestId` / `zRequestUtcDay` scalar properties for joining data. Duplicate request ID reservations are rejected (409), invalid ID (422), unavailable reservation storage (503). For guaranteed cross-replica uniqueness, use the analytics-named shared DB reservation adapter or inject an atomic shared `requestIds` store. Memory-only reservation is development-only.
|
|
58
|
+
|
|
59
|
+
### Editing tags
|
|
60
|
+
|
|
61
|
+
Navigate to `/_tags/admin` on the selected dashboard host with an authenticated application session. Create/edit triggers, event matches and conditions; variables can read event properties, dataLayer values, or request context; tags can fire internally, to a configured GA4 stream, or to a preapproved webhook. `/_tags/admin/preview` is a protected **dry run** that shows eligible triggers/tags without dispatching to destinations. The full JSON editor can edit uncommon fields; saving a draft and publishing it are separately authorized, version-checked operations. Registered variables can be used as exact parameter bindings such as `parameters:{revenue:'{{cart_total}}'}`; JavaScript expressions are never evaluated.
|
|
62
|
+
|
|
63
|
+
A GA4 tag needs `measurementId` (e.g., `G-ABC1234`), `credentialRef` referencing the server-side secret, and a **client_id** via explicit sessionId or a variable; Zerux does not silently invent a cookie or fingerprint. GA4 HTTP success means received, **not necessarily processed**. See [Google's Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events). Webhooks are referenced by `destinationRef` to a server allowlist (not an attacker-controlled URL). Use egress filtering/DNS rebinding protection in production.
|
|
64
|
+
|
|
65
|
+
### Deployment prerequisites
|
|
66
|
+
|
|
67
|
+
Connect `analytics.analytics` to a real named @zeruxjs/db connector. Its facade should implement `createTable`, atomic `insert`, `select`, conditional `update` and reliable `affectedCount`. The default store uses `analytics_tag_containers` with optimistic CAS; `analytics_request_ids` enforces UTC-day ID uniqueness and can optionally prune old IDs if the connector supports `delete`. Multi-server nodes must share the same DB and dashboard store; keep admin APIs protected behind HTTPS session auth, CSRF restrictions, reverse proxy limits and role controls. Supply `store`/`requestIds` adapters if your DB is not compatible. Monitor warnings, 409/429/503 rates and outgoing destination failures. No durable queue/replay is implemented yet.
|
|
68
|
+
|
|
69
|
+
### Optional automatic web triggers
|
|
70
|
+
|
|
71
|
+
Published `pageview`, `click`, `form`, and `history` trigger types cause the generated bootstrap to register corresponding browser listeners. They emit `page_view`, `click`, `form_submit`, and `history_change` events respectively. Click/form metadata intentionally excludes element text and form field values. Automatic tracking is not enabled unless a corresponding trigger is published. Set `tags.defaultConsent:'denied'` for a site's privacy policy to suppress telemetry before the application calls `ttag('consent','granted')`; this switch is independent of email marketing subscription state. `history` currently listens to `popstate` rather than monkeypatching routing APIs.
|
|
72
|
+
|
|
73
|
+
## Section 72: publishable releases and governance
|
|
74
|
+
|
|
75
|
+
**Important migration:** `PUT /_tags/admin/api` now **saves a draft**, not the live container. A separate privileged `POST /_tags/admin/publish` makes the draft live. Existing plain versioned containers are treated as live **release 1** until their first draft change; the first save persists a governance envelope in the existing CAS document. No new table is required. Two manager replicas must share the same atomic versioned document store and request-ID adapter; an in-memory store is for development only.
|
|
76
|
+
|
|
77
|
+
Configure **both** independent authorizers on the server; no publish or rollback is allowed when `authorizePublish` is absent:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const tagConfig = {
|
|
81
|
+
site: 'shop',
|
|
82
|
+
allowedOrigins: ['https://shop.example.com'],
|
|
83
|
+
siteByOrigin: { 'https://shop.example.com': 'shop' },
|
|
84
|
+
defaultConsent: 'denied', // for browser AND astag by default
|
|
85
|
+
requireBrowserConsent: true, // drops unknown client-consent claims
|
|
86
|
+
botPolicy: 'drop-known',
|
|
87
|
+
dashboardHost: 'tags.example.com',
|
|
88
|
+
authorizeAdmin: req => checkRole(req, 'analytics_editor'),
|
|
89
|
+
authorizePublish: req => checkRole(req, 'analytics_publisher'),
|
|
90
|
+
onWarning: message => logger.warn(message),
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
An authorizer must verify the user's authenticated session, tenant and role. Both controls are checked for publisher actions; a publisher needs editor/admin access too. All POST/PUT studio writes require same-origin `Origin` checks, in addition to these authorizers. Never expose a dashboard publicly. Add CSRF tokens in the host layer if your deployment accepts unusual state-changing request patterns.
|
|
95
|
+
|
|
96
|
+
| Route | Method | Permission | Purpose |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| `/_tags/admin` | GET | editor | Visual draft editor, dry-run, publish and rollback controls |
|
|
99
|
+
| `/_tags/admin/api` | GET / PUT | editor | Read full draft / save `{version: prior+1,...}` |
|
|
100
|
+
| `/_tags/admin/status` | GET | editor | Draft revision, live release and bounded release-history metadata |
|
|
101
|
+
| `/_tags/admin/preview` | POST | editor | Pure trigger dry-run `{name,properties,data,mode:'draft'|'published'}` |
|
|
102
|
+
| `/_tags/admin/publish` | POST | editor + publisher | `{expectedVersion}`; atomic CAS live switch and history append |
|
|
103
|
+
| `/_tags/admin/rollback` | POST | editor + publisher | `{expectedVersion,releaseId}`; publish a retained snapshot as a new live release |
|
|
104
|
+
| `/_tags/ttag.js` | GET | public | Bootstrap **from published triggers only**, `Cache-Control: no-store` |
|
|
105
|
+
| `/_tags/collect` | POST | allowed origin or server bearer token | Browser/server telemetry, validated and fail-soft |
|
|
106
|
+
|
|
107
|
+
The editor shows draft and live revision independently; **saving never fires edited tags**. Preview is pure and does not send to GA4/webhooks or internal storage. Publish rejects missing GA4 credentials or webhook destination references; credentials, actual URL/headers and OAuth tokens are always supplied by trusted server configuration, never dashboard JSON. Rollback preserves the current draft so a publisher may fix/re-publish it later. Revisions are monotonic and concurrent conflicting writers receive HTTP 409. The atomic single-document scheme supports a 12-entry release history, compacted further to keep the complete document below 58,000 bytes for portable SQL `TEXT` implementations. Oversized definitions are rejected until simplified; historical snapshots pruned by the retention policy are not available for rollback. A rollback is a new release, not mutation of old history.
|
|
108
|
+
|
|
109
|
+
`ttag('config', {site:..., debug:...})` is **no longer accepted**: an untrusted web page may not change the server-issued tenant or debug policy. Arbitrary JavaScript/HTML tags are **not supported**. The studio uses an exact SHA-256 hash for inline script CSP, with scripts otherwise denied; it also denies framing and uses no-store responses. Automatic click/form listeners avoid form field values, text contents and URLs. Browser telemetry uses opaque random request IDs, bounded scalar properties and client-side consent. `ttag('consent','denied')` discards queued events. `defaultConsent:'denied'` gates `astag` until it receives `astag('consent','granted')`, and enables consent checks on browser ingestion by default. The collector drops explicit `denied` claims and, with `requireBrowserConsent:true`, ignores unknown consent. **A browser's `granted` claim cannot prove legal consent**: host consent management and privacy policies remain essential. `siteByOrigin` should be configured for every public origin in multi-tenant deployments; collection-side binding rejects mismatched site assertions. The system never infers identity from IPs or browser fingerprints.
|
|
110
|
+
|
|
111
|
+
External destinations have independently bounded 1-second timeouts and failures isolate each tag, with sanitized warnings. Webhook destinations must use public HTTPS DNS hostnames and redirects are disallowed; this **does not remove DNS-rebinding risk**. Enforce outbound DNS/IP allowlists and egress firewall rules in your deployment. GA4 HTTP acceptance is not proof of Google-side processing. No arbitrary remote script execution, guaranteed browser delivery, distributed rate limiting, replay queue or unbounded GTM/GA4 feature parity is provided. Large production deployments should migrate release snapshots to a normalized transactional release table rather than depending on the capped JSON envelope.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** No browser bundles/dependencies required: the generated bootstrap is self-contained. */
|
|
2
|
+
export declare function browserScript(config: {
|
|
3
|
+
endpoint: string;
|
|
4
|
+
site: string;
|
|
5
|
+
debug: boolean;
|
|
6
|
+
autoEvents?: string[];
|
|
7
|
+
defaultConsent?: 'granted' | 'denied';
|
|
8
|
+
}): string;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** No browser bundles/dependencies required: the generated bootstrap is self-contained. */
|
|
2
|
+
export function browserScript(config) {
|
|
3
|
+
const settings = JSON.stringify(config).replace(/</g, '\\u003c');
|
|
4
|
+
return `(function(){'use strict';
|
|
5
|
+
if(window.__zeruxTtagLoaded===true)return;window.__zeruxTtagLoaded=true;
|
|
6
|
+
var cfg=${settings}, alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
7
|
+
var data=window.dataLayer=Array.isArray(window.dataLayer)?window.dataLayer:[];
|
|
8
|
+
var state=Object.create(null),consent=cfg.defaultConsent||'granted',pending=[],scheduled=false;
|
|
9
|
+
function makeId(){if(!window.crypto||!window.crypto.getRandomValues)throw new Error('Secure random unavailable');var str='';while(str.length<16){var bytes=new Uint8Array(32);window.crypto.getRandomValues(bytes);for(var i=0;i<bytes.length&&str.length<16;i++){if(bytes[i]<248)str+=alphabet.charAt(bytes[i]%62);}}return str;}
|
|
10
|
+
function safe(v){var out=Object.create(null);if(!v||typeof v!=='object'||Array.isArray(v))return out;
|
|
11
|
+
var keys=Object.keys(v);for(var i=0;i<Math.min(keys.length,40);i++){var k=keys[i],val=v[k];
|
|
12
|
+
if(!/^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/.test(k)||k==='constructor'||k==='prototype'||k==='__proto__'||k==='zRequestId'||k==='zRequestUtcDay')continue;
|
|
13
|
+
if(val===null||typeof val==='boolean'||typeof val==='string'&&val.length<=1024||typeof val==='number'&&Number.isFinite(val))out[k]=val;
|
|
14
|
+
}return out;}
|
|
15
|
+
function log(){if(cfg.debug&&window.console&&console.debug)console.debug.apply(console,['[ttag]'].concat(Array.prototype.slice.call(arguments)));}
|
|
16
|
+
function send(item,attempt){if(consent!=='granted')return;var body=JSON.stringify(item);try{
|
|
17
|
+
if(navigator.sendBeacon){var blob=new Blob([body],{type:'text/plain;charset=UTF-8'});if(navigator.sendBeacon(cfg.endpoint,blob)){log('beacon queued',item.requestId);return;}}
|
|
18
|
+
if(window.fetch){fetch(cfg.endpoint,{method:'POST',headers:{'content-type':'text/plain;charset=UTF-8'},body:body,keepalive:true,credentials:'omit',cache:'no-store'}).then(function(res){if(res.status===409&&(attempt||0)<1&&consent==='granted'){item.requestId=makeId();send(item,1);}}).catch(function(){log('delivery unavailable');});}
|
|
19
|
+
}catch(e){log('delivery unavailable');}}
|
|
20
|
+
function flush(){scheduled=false;var batch=pending.splice(0);for(var i=0;i<batch.length;i++)send(batch[i]);}
|
|
21
|
+
function queue(event){if(consent!=='granted')return null;var id=makeId();
|
|
22
|
+
var item={requestId:id,name:String(event.event),source:'web',site:cfg.site,pathname:location.pathname,consent:'granted',properties:safe(event.properties),data:safe(state)};
|
|
23
|
+
if(typeof state.sessionId==='string'&&state.sessionId.length<=128)item.sessionId=state.sessionId;
|
|
24
|
+
pending.push(item);if(!scheduled){scheduled=true;Promise.resolve().then(flush);}return id;
|
|
25
|
+
}
|
|
26
|
+
var originalPush=data.push.bind(data);
|
|
27
|
+
data.push=function(){var result=originalPush.apply(data,arguments);for(var i=0;i<arguments.length;i++){var item=arguments[i];if(item&&typeof item==='object'&&!Array.isArray(item)&&typeof item.event==='string'&&item.event)queue(item);}return result;};
|
|
28
|
+
function ttag(command,a,b){if(command==='event'){
|
|
29
|
+
var event={event:String(a),properties:safe(b)};originalPush(event);return queue(event);
|
|
30
|
+
}
|
|
31
|
+
if(command==='set'){if(typeof a==='string'&&/^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/.test(a)&&a!=='constructor'&&a!=='prototype'&&a!=='__proto__'&&(typeof b==='string'&&b.length<=1024||typeof b==='number'&&Number.isFinite(b)||typeof b==='boolean'||b===null)){state[a]=b;data.push({set:a,value:b});return true;}return false;}
|
|
32
|
+
if(command==='get')return a==='site'?cfg.site:a==='debug'?cfg.debug:state[a];
|
|
33
|
+
if(command==='consent'){if(a==='granted'||a==='denied'){consent=a;if(a==='denied')pending.length=0;log('consent',a);return true;}return false;}
|
|
34
|
+
if(command==='config')return false; // Site and debug flags are controlled by the server.
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
window.ttag=ttag;
|
|
38
|
+
for(var i=0,initial=data.slice();i<initial.length;i++)if(initial[i]&&typeof initial[i].event==='string')queue(initial[i]);
|
|
39
|
+
if(document.addEventListener){document.addEventListener('visibilitychange',function(){if(document.visibilityState==='hidden')flush();});window.addEventListener('pagehide',flush);
|
|
40
|
+
var auto=cfg.autoEvents||[];
|
|
41
|
+
if(auto.indexOf('pageview')!==-1)ttag('event','page_view',{path:location.pathname});
|
|
42
|
+
if(auto.indexOf('click')!==-1)document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[data-ttag-click]'):null;if(!el)return;
|
|
43
|
+
ttag('event','click',{element:String(el.tagName||'').toLowerCase().slice(0,30),element_id:String(el.id||'').slice(0,96)});
|
|
44
|
+
},{capture:true,passive:true});
|
|
45
|
+
if(auto.indexOf('form')!==-1)document.addEventListener('submit',function(e){var el=e.target;
|
|
46
|
+
ttag('event','form_submit',{form_id:String(el&&el.id||'').slice(0,96)});
|
|
47
|
+
},{capture:true,passive:true});
|
|
48
|
+
if(auto.indexOf('history')!==-1)window.addEventListener('popstate',function(){ttag('event','history_change',{path:location.pathname});});
|
|
49
|
+
}
|
|
50
|
+
log('initialized');
|
|
51
|
+
})();`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TagContainer, TagContainerStore } from './types.js';
|
|
2
|
+
export declare function validateContainer(data: unknown): TagContainer;
|
|
3
|
+
export declare function createMemoryTagStore(initial: TagContainer): TagContainerStore;
|
|
4
|
+
export declare const emptyTagContainer: (id?: string) => TagContainer;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const ident = /^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/;
|
|
2
|
+
const valid = (name) => typeof name === 'string' && ident.test(name);
|
|
3
|
+
// eslint-disable-next-line no-control-regex -- Intentionally matches control characters for validation or output sanitization.
|
|
4
|
+
const label = (s) => typeof s === 'string' && s.length >= 1 && s.length <= 128 && !/[\x00-\x1f]/.test(s);
|
|
5
|
+
const scalar = (v) => v === null || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 1024) || (typeof v === 'number' && Number.isFinite(v));
|
|
6
|
+
export function validateContainer(data) {
|
|
7
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
8
|
+
throw new TypeError('Invalid tag container');
|
|
9
|
+
const c = data;
|
|
10
|
+
if (!valid(c.id) || !Number.isSafeInteger(c.version) || c.version < 1 || typeof c.name !== 'string' || c.name.length > 128)
|
|
11
|
+
throw new TypeError('Invalid container identity');
|
|
12
|
+
if (!Array.isArray(c.triggers) || !Array.isArray(c.tags) || !Array.isArray(c.variables) || c.triggers.length > 200 || c.tags.length > 200 || c.variables.length > 200)
|
|
13
|
+
throw new TypeError('Invalid container limits');
|
|
14
|
+
const ids = new Set();
|
|
15
|
+
const register = (id) => { if (!valid(id) || ids.has(id))
|
|
16
|
+
throw new TypeError('Invalid or duplicate tag ID'); ids.add(id); };
|
|
17
|
+
for (const t of c.triggers) {
|
|
18
|
+
register(t.id);
|
|
19
|
+
if (!label(t.name) || !['event', 'all', 'pageview', 'click', 'form', 'history'].includes(t.type) || t.enabled !== undefined && typeof t.enabled !== 'boolean' || t.type === 'event' && !valid(t.event))
|
|
20
|
+
throw new TypeError('Invalid trigger');
|
|
21
|
+
if (t.conditions !== undefined) {
|
|
22
|
+
if (!Array.isArray(t.conditions) || t.conditions.length > 20)
|
|
23
|
+
throw new TypeError('Too many trigger conditions');
|
|
24
|
+
for (const cond of t.conditions)
|
|
25
|
+
if (!valid(cond.field) || !['eq', 'neq', 'contains', 'exists', 'gte', 'lte'].includes(cond.operator) || cond.value !== undefined && !scalar(cond.value))
|
|
26
|
+
throw new TypeError('Invalid trigger condition');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const v of c.variables) {
|
|
30
|
+
register(v.id);
|
|
31
|
+
if (!label(v.name) || !['constant', 'event', 'dataLayer', 'context'].includes(v.type) || v.key !== undefined && !valid(v.key) || v.value !== undefined && !scalar(v.value))
|
|
32
|
+
throw new TypeError('Invalid variable');
|
|
33
|
+
}
|
|
34
|
+
for (const t of c.tags) {
|
|
35
|
+
register(t.id);
|
|
36
|
+
if (!label(t.name) || !['analytics', 'ga4', 'webhook'].includes(t.type) || !Array.isArray(t.triggerIds) || !t.triggerIds.length || t.triggerIds.length > 40 || t.enabled !== undefined && typeof t.enabled !== 'boolean' || t.triggerIds.some(id => !c.triggers.some(trigger => trigger.id === id)))
|
|
37
|
+
throw new TypeError('Invalid tag');
|
|
38
|
+
if (t.eventName !== undefined && !valid(t.eventName))
|
|
39
|
+
throw new TypeError('Invalid destination event');
|
|
40
|
+
if (t.type === 'ga4' && (!t.measurementId || !/^G-[A-Z0-9]{5,20}$/.test(t.measurementId) || !valid(t.credentialRef)))
|
|
41
|
+
throw new TypeError('Invalid GA4 tag');
|
|
42
|
+
if (t.type === 'webhook' && !valid(t.destinationRef))
|
|
43
|
+
throw new TypeError('Invalid destination reference');
|
|
44
|
+
if (t.clientIdVariable !== undefined && !c.variables.some(v => v.id === t.clientIdVariable))
|
|
45
|
+
throw new TypeError('Unknown client ID variable');
|
|
46
|
+
if (t.parameters !== undefined) {
|
|
47
|
+
if (!t.parameters || Array.isArray(t.parameters) || Object.keys(t.parameters).length > 25)
|
|
48
|
+
throw new TypeError('Invalid parameters');
|
|
49
|
+
for (const [key, value] of Object.entries(t.parameters)) {
|
|
50
|
+
if (!valid(key) || ['zRequestId', 'zRequestUtcDay', '__proto__', 'constructor', 'prototype'].includes(key) || !scalar(value))
|
|
51
|
+
throw new TypeError('Invalid parameter');
|
|
52
|
+
if (typeof value === 'string' && (value.includes('{{') || value.includes('}}'))) {
|
|
53
|
+
const match = /^\{\{([A-Za-z][A-Za-z0-9_.:-]{0,95})\}\}$/.exec(value);
|
|
54
|
+
if (!match || !c.variables.some(variable => variable.id === match[1]))
|
|
55
|
+
throw new TypeError('Unknown or malformed parameter variable');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Strip unrecognized object keys and prototypes, preventing accidental disclosure of secrets.
|
|
61
|
+
const base = JSON.parse(JSON.stringify({ id: c.id, version: c.version, name: c.name, triggers: c.triggers.map(({ id, name, type, event, conditions, enabled }) => ({ id, name, type, event, conditions, enabled })), variables: c.variables.map(({ id, name, type, key, value }) => ({ id, name, type, key, value })), tags: c.tags.map(({ id, name, type, triggerIds, enabled, eventName, measurementId, credentialRef, destinationRef, clientIdVariable, parameters }) => ({ id, name, type, triggerIds, enabled, eventName, measurementId, credentialRef, destinationRef, clientIdVariable, parameters })) }));
|
|
62
|
+
if (c.governance !== undefined) {
|
|
63
|
+
const g = c.governance;
|
|
64
|
+
if (!g || typeof g !== 'object' || Array.isArray(g) || !Number.isSafeInteger(g.release) || g.release < 1 || !Array.isArray(g.history) || g.history.length < 1 || g.history.length > 12)
|
|
65
|
+
throw new TypeError('Invalid release metadata');
|
|
66
|
+
const snapshot = (raw) => {
|
|
67
|
+
if (!raw || typeof raw !== 'object' || Object.hasOwn(raw, 'governance'))
|
|
68
|
+
throw new TypeError('Nested releases are forbidden');
|
|
69
|
+
const value = validateContainer(raw);
|
|
70
|
+
if (value.id !== base.id)
|
|
71
|
+
throw new TypeError('Cross-container release rejected');
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
const published = snapshot(g.published), history = g.history.map(entry => {
|
|
75
|
+
if (!entry || !Number.isSafeInteger(entry.id) || entry.id < 1 || typeof entry.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(entry.at) ||
|
|
76
|
+
!['initial', 'publish', 'rollback'].includes(entry.action))
|
|
77
|
+
throw new TypeError('Invalid release history');
|
|
78
|
+
return { id: entry.id, at: entry.at, action: entry.action, container: snapshot(entry.container) };
|
|
79
|
+
});
|
|
80
|
+
if (history.some((entry, i) => i > 0 && entry.id <= history[i - 1].id) || history.at(-1)?.id !== g.release ||
|
|
81
|
+
JSON.stringify(history.at(-1)?.container) !== JSON.stringify(published))
|
|
82
|
+
throw new TypeError('Incoherent release history');
|
|
83
|
+
base.governance = { release: g.release, published, history };
|
|
84
|
+
}
|
|
85
|
+
return base;
|
|
86
|
+
}
|
|
87
|
+
export function createMemoryTagStore(initial) {
|
|
88
|
+
let current = validateContainer(initial);
|
|
89
|
+
return { async load() { return structuredClone(current); }, async save(next, expected) {
|
|
90
|
+
if (expected !== current.version || next.version !== expected + 1)
|
|
91
|
+
return false;
|
|
92
|
+
current = validateContainer(next);
|
|
93
|
+
return true;
|
|
94
|
+
} };
|
|
95
|
+
}
|
|
96
|
+
export const emptyTagContainer = (id = 'default') => ({ id, name: 'Default', version: 1, triggers: [{ id: 'all_events', name: 'All Events', type: 'all', enabled: true }], tags: [{ id: 'internal_analytics', name: 'Internal Analytics', type: 'analytics', triggerIds: ['all_events'], enabled: true }], variables: [] });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function dashboard(prefix: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function dashboard(prefix) {
|
|
2
|
+
const api = JSON.stringify(prefix + '/admin/api');
|
|
3
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<title>Zerux Tags — Container Manager</title><style>
|
|
5
|
+
:root{color-scheme:light dark;font:15px system-ui,sans-serif}body{max-width:1000px;margin:0 auto;padding:22px}h1{margin-bottom:3px}p{color:#777}
|
|
6
|
+
nav{display:flex;gap:8px;flex-wrap:wrap;margin:22px 0}button{cursor:pointer;border:1px solid #888;border-radius:6px;padding:9px 14px;background:transparent;color:inherit}button:hover{opacity:.8}
|
|
7
|
+
section{border:1px solid #8885;padding:16px;border-radius:10px;margin:12px 0}label{display:block;margin:8px 0}input,select,textarea{padding:10px;box-sizing:border-box;width:100%;border:1px solid #8887;border-radius:6px;background:transparent;color:inherit}textarea{min-height:160px;font:13px monospace}
|
|
8
|
+
.two{display:grid;grid-template-columns:1fr 1fr;gap:12px}.help{font-size:13px}pre{white-space:pre-wrap;overflow-wrap:anywhere}strong{color:inherit}.small{font-size:12px}
|
|
9
|
+
</style></head><body><h1>Zerux Tags</h1><p>Versioned triggers, tags and variables. All destination credentials stay on the server.</p>
|
|
10
|
+
<nav><button id="reload">Reload</button><button id="addTrigger">Add trigger</button><button id="addVariable">Add variable</button><button id="addTag">Add tag</button><button id="save">Save draft</button><button id="publish">Publish draft</button><button id="test">Dry-run trigger</button></nav>
|
|
11
|
+
<main><section><label>Container name<input id="name"></label><div class="small" id="version"></div><div class="small" id="release"></div></section>
|
|
12
|
+
<section><h2>Triggers</h2><div id="triggers"></div></section><section><h2>Variables</h2><div id="variables"></div></section><section><h2>Tags</h2><div id="tags"></div></section>
|
|
13
|
+
<section><h2>Release history and rollback</h2><label>Restore published release<select id="rollbackRelease"></select></label><button id="rollback">Roll back live release</button><p class="help">Rollback changes the live version without deleting the current draft. Publishing requires an independent publisher role.</p></section>
|
|
14
|
+
<section><h2>Dry-run debug</h2><label>Inspect<select id="previewMode"><option value="draft">Draft</option><option value="published">Published</option></select></label><label>Event name<input id="testEvent" value="page_view"></label><label>Properties JSON<textarea id="testProps" spellcheck="false">{}</textarea></label><pre id="testOutput"></pre></section>
|
|
15
|
+
<section><h2>Advanced JSON editor</h2><p class="help">Edit the draft. Save increments its revision; publish is a separate authorized operation. A 409 requires reload and re-apply.</p><textarea id="editor" spellcheck="false"></textarea></section>
|
|
16
|
+
<pre role="status" aria-live="polite" id="status"></pre></main>
|
|
17
|
+
<script>(function(){'use strict';const api=${api},$=id=>document.getElementById(id);let current,latestStatus;
|
|
18
|
+
const say=t=>$('status').textContent=t;
|
|
19
|
+
const input=(label,val,callback)=>{const box=document.createElement('label');box.textContent=label;const el=document.createElement('input');el.value=val==null?'':String(val);el.onchange=()=>callback(el.value);box.append(el);return box;};
|
|
20
|
+
function picker(label,value,options,cb){const box=document.createElement('label');box.textContent=label;const select=document.createElement('select');for(const entry of options){const option=document.createElement('option');option.value=entry;option.textContent=entry;select.append(option);}select.value=value;select.onchange=()=>cb(select.value);box.append(select);return box;}
|
|
21
|
+
function renderList(id,arr,fields){const container=$(id);container.replaceChildren();arr.forEach((obj,i)=>{const section=document.createElement('section');const title=document.createElement('strong');title.textContent=obj.name||obj.id;section.append(title);
|
|
22
|
+
fields.forEach(([field,name])=>section.append(input(name,obj[field],v=>{obj[field]=v;render();})));
|
|
23
|
+
if(id==='triggers'){section.append(picker('Trigger type',obj.type,['all','event','pageview','click','form','history'],v=>{obj.type=v;render();}));section.append(input('Conditions (JSON)',JSON.stringify(obj.conditions||[]),v=>{try{obj.conditions=JSON.parse(v);render();}catch{say('Invalid conditions JSON');}}));}
|
|
24
|
+
if(id==='variables'){section.append(picker('Variable type',obj.type,['constant','event','dataLayer','context'],v=>{obj.type=v;render();}));section.append(input('Constant value',obj.value??'',v=>{obj.value=v;render();}));}
|
|
25
|
+
if(id==='tags'){section.append(picker('Tag destination',obj.type,['analytics','ga4','webhook'],v=>{obj.type=v;render();}));
|
|
26
|
+
for(const field of ['measurementId','credentialRef','destinationRef','clientIdVariable'])section.append(input(field,obj[field],v=>{obj[field]=v;render();}));
|
|
27
|
+
section.append(input('Trigger IDs (comma-separated)',obj.triggerIds.join(','),v=>{obj.triggerIds=v.split(',').map(x=>x.trim()).filter(Boolean);render();}));}
|
|
28
|
+
const del=document.createElement('button');del.textContent='Delete';del.onclick=()=>{arr.splice(i,1);render();};section.append(del);container.append(section);});}
|
|
29
|
+
function render(){if(!current)return;$('name').value=current.name;$('version').textContent='Draft revision: '+current.version;
|
|
30
|
+
renderList('triggers',current.triggers,[['id','ID'],['name','Name'],['event','Event (for event trigger)']]);
|
|
31
|
+
renderList('variables',current.variables,[['id','ID'],['name','Name'],['key','Property key']]);
|
|
32
|
+
renderList('tags',current.tags,[['id','ID'],['name','Name'],['eventName','Event override']]);
|
|
33
|
+
$('editor').value=JSON.stringify(current,null,2);}
|
|
34
|
+
async function load(){try{const r=await fetch(api,{credentials:'same-origin',cache:'no-store'});if(!r.ok)throw Error('HTTP '+r.status);current=await r.json();render();await refreshStatus();say('Draft loaded');}catch(e){say('Load failed: '+e.message);}}
|
|
35
|
+
async function refreshStatus(){const r=await fetch(api.slice(0,-3)+'status',{credentials:'same-origin',cache:'no-store'});if(!r.ok)throw Error('Status HTTP '+r.status);latestStatus=await r.json();
|
|
36
|
+
$('release').textContent='Published release: '+latestStatus.publishedRelease+' (draft unpublished: '+latestStatus.unpublishedChanges+')';
|
|
37
|
+
const picker=$('rollbackRelease');picker.replaceChildren();for(const entry of latestStatus.history.slice().reverse()){
|
|
38
|
+
const option=document.createElement('option');option.value=String(entry.id);option.textContent='#'+entry.id+' '+entry.action+' at '+entry.at;picker.append(option);
|
|
39
|
+
}}
|
|
40
|
+
async function transition(endpoint,body){const r=await fetch(api.slice(0,-3)+endpoint,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
41
|
+
const payload=await r.json();if(!r.ok)throw Error(payload.error||r.status);await load();return payload;}
|
|
42
|
+
$('reload').onclick=load;$('name').onchange=e=>{current.name=e.target.value;render();};
|
|
43
|
+
$('addTrigger').onclick=()=>{const id='trigger_'+Date.now().toString(36);current.triggers.push({id,name:id,type:'event',event:'page_view'});render();};
|
|
44
|
+
$('addVariable').onclick=()=>{const id='var_'+Date.now().toString(36);current.variables.push({id,name:id,type:'event',key:'value'});render();};
|
|
45
|
+
$('addTag').onclick=()=>{const id='tag_'+Date.now().toString(36);current.tags.push({id,name:id,type:'analytics',triggerIds:[current.triggers[0]?.id||'all_events']});render();};
|
|
46
|
+
$('test').onclick=async()=>{try{const res=await fetch(api.slice(0,-3)+'preview',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({name:$('testEvent').value,properties:JSON.parse($('testProps').value),mode:$('previewMode').value})});$('testOutput').textContent=JSON.stringify(await res.json(),null,2);}catch(e){say('Preview failed: '+e.message);}};
|
|
47
|
+
$('save').onclick=async()=>{try{const proposal=JSON.parse($('editor').value);delete proposal.governance;proposal.version=current.version+1;const r=await fetch(api,{method:'PUT',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(proposal)});const body=await r.json();if(!r.ok)throw Error(body.error||r.status);current=proposal;await load();say('Saved draft revision '+body.version+'; not live until published');}catch(e){say('Publish failed: '+e.message);}};
|
|
48
|
+
$('publish').onclick=async()=>{try{await transition('publish',{expectedVersion:current.version});say('Draft published successfully');}catch(e){say('Publish failed: '+e.message);}};
|
|
49
|
+
$('rollback').onclick=async()=>{try{const id=Number($('rollbackRelease').value);if(!Number.isSafeInteger(id))throw Error('Select a release');await transition('rollback',{expectedVersion:current.version,releaseId:id});say('Live release rolled back; draft unchanged');}catch(e){say('Rollback failed: '+e.message);}};
|
|
50
|
+
load();})();</script></body></html>`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Durable governance is stored inside the same version-CAS document as the draft.
|
|
2
|
+
* A worker can never observe a partial draft/publish transition. */
|
|
3
|
+
import type { TagContainer, TagContainerStore } from './types.js';
|
|
4
|
+
/** Existing single-container documents are treated as release #1 until the first edit. */
|
|
5
|
+
export declare function publishedContainer(document: TagContainer): TagContainer;
|
|
6
|
+
export declare function releaseSummary(document: TagContainer): {
|
|
7
|
+
draftRevision: number;
|
|
8
|
+
publishedRelease: number;
|
|
9
|
+
publishedRevision: number;
|
|
10
|
+
unpublishedChanges: boolean;
|
|
11
|
+
history: {
|
|
12
|
+
id: number;
|
|
13
|
+
at: string;
|
|
14
|
+
action: "initial" | "publish" | "rollback";
|
|
15
|
+
revision: number;
|
|
16
|
+
}[];
|
|
17
|
+
};
|
|
18
|
+
export declare function saveTagDraft(store: TagContainerStore, next: TagContainer, expectedVersion: number): Promise<boolean>;
|
|
19
|
+
export declare function publishTagDraft(store: TagContainerStore, expectedVersion: number): Promise<boolean>;
|
|
20
|
+
export declare function rollbackTagRelease(store: TagContainerStore, expectedVersion: number, releaseId: number): Promise<boolean>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { validateContainer } from './container.js';
|
|
2
|
+
function snapshot(input) {
|
|
3
|
+
const { governance: _unused, ...definition } = input;
|
|
4
|
+
return validateContainer(definition);
|
|
5
|
+
}
|
|
6
|
+
/** analytics_tag_containers uses a portable SQL TEXT field (MySQL <= 64 KiB).
|
|
7
|
+
* Prune oldest retained releases before updating the CAS row; never prune live. */
|
|
8
|
+
const MAX_DOCUMENT_BYTES = 58_000;
|
|
9
|
+
function bounded(document) {
|
|
10
|
+
const checked = validateContainer(document);
|
|
11
|
+
while (Buffer.byteLength(JSON.stringify(checked), 'utf8') > MAX_DOCUMENT_BYTES && (checked.governance?.history.length ?? 0) > 1) {
|
|
12
|
+
checked.governance.history.shift();
|
|
13
|
+
}
|
|
14
|
+
if (Buffer.byteLength(JSON.stringify(checked), 'utf8') > MAX_DOCUMENT_BYTES)
|
|
15
|
+
throw new RangeError('Tag release document exceeds portable storage limit');
|
|
16
|
+
return checked;
|
|
17
|
+
}
|
|
18
|
+
function initial(input) {
|
|
19
|
+
const base = snapshot(input);
|
|
20
|
+
return { release: 1, published: base, history: [{ id: 1, at: new Date().toISOString(), action: 'initial', container: base }] };
|
|
21
|
+
}
|
|
22
|
+
function append(g, container, action) {
|
|
23
|
+
const published = snapshot(container), release = g.release + 1;
|
|
24
|
+
const record = { id: release, at: new Date().toISOString(), action, container: published };
|
|
25
|
+
return { release, published, history: [...g.history, record].slice(-12) };
|
|
26
|
+
}
|
|
27
|
+
/** Existing single-container documents are treated as release #1 until the first edit. */
|
|
28
|
+
export function publishedContainer(document) {
|
|
29
|
+
const parsed = validateContainer(document);
|
|
30
|
+
return snapshot(parsed.governance?.published ?? parsed);
|
|
31
|
+
}
|
|
32
|
+
export function releaseSummary(document) {
|
|
33
|
+
const parsed = validateContainer(document), g = parsed.governance ?? initial(parsed);
|
|
34
|
+
return { draftRevision: parsed.version, publishedRelease: g.release, publishedRevision: g.published.version,
|
|
35
|
+
unpublishedChanges: JSON.stringify(snapshot(parsed)) !== JSON.stringify(g.published),
|
|
36
|
+
history: g.history.map(({ id, at, action, container }) => ({ id, at, action, revision: container.version })) };
|
|
37
|
+
}
|
|
38
|
+
export async function saveTagDraft(store, next, expectedVersion) {
|
|
39
|
+
const current = validateContainer(await store.load());
|
|
40
|
+
if (current.version !== expectedVersion || next.version !== expectedVersion + 1 || next.id !== current.id)
|
|
41
|
+
return false;
|
|
42
|
+
// Governance is server-controlled; ignore any metadata sent by the editor.
|
|
43
|
+
return store.save(bounded({ ...snapshot(next), governance: current.governance ?? initial(current) }), expectedVersion);
|
|
44
|
+
}
|
|
45
|
+
export async function publishTagDraft(store, expectedVersion) {
|
|
46
|
+
const current = validateContainer(await store.load());
|
|
47
|
+
if (current.version !== expectedVersion)
|
|
48
|
+
return false;
|
|
49
|
+
const governance = append(current.governance ?? initial(current), current, 'publish');
|
|
50
|
+
return store.save(bounded({ ...current, version: current.version + 1, governance }), expectedVersion);
|
|
51
|
+
}
|
|
52
|
+
export async function rollbackTagRelease(store, expectedVersion, releaseId) {
|
|
53
|
+
const current = validateContainer(await store.load());
|
|
54
|
+
if (current.version !== expectedVersion)
|
|
55
|
+
return false;
|
|
56
|
+
const governance = current.governance ?? initial(current);
|
|
57
|
+
const entry = governance.history.find(item => item.id === releaseId);
|
|
58
|
+
if (!entry)
|
|
59
|
+
throw new RangeError('Release not available in retained history');
|
|
60
|
+
const updated = append(governance, entry.container, 'rollback');
|
|
61
|
+
return store.save(bounded({ ...current, version: current.version + 1, governance: updated }), expectedVersion);
|
|
62
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TagContainer, TagEvent, TagRuntimeOptions, TagContainerStore } from './types.js';
|
|
2
|
+
export declare function createTagManager(options: TagRuntimeOptions): {
|
|
3
|
+
createAstag: (context?: {
|
|
4
|
+
requestId?: string;
|
|
5
|
+
requestUtcDay?: string;
|
|
6
|
+
site?: string;
|
|
7
|
+
pathname?: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
}) => (command: "event" | "config" | "set" | "get" | "consent", arg?: string | Record<string, unknown>, value?: unknown) => Promise<unknown>;
|
|
10
|
+
evaluate: (event: TagEvent) => Promise<{
|
|
11
|
+
requestId: string;
|
|
12
|
+
requestUtcDay: string;
|
|
13
|
+
matchedTriggers: string[];
|
|
14
|
+
firedTags: string[];
|
|
15
|
+
failures: string[];
|
|
16
|
+
}>;
|
|
17
|
+
container: () => Promise<TagContainer>;
|
|
18
|
+
published: () => Promise<TagContainer>;
|
|
19
|
+
store: TagContainerStore;
|
|
20
|
+
routes(): {
|
|
21
|
+
pattern: string;
|
|
22
|
+
handler: (request: Request) => Promise<Response>;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { createAnalyticsCollector, requestUtcDay, reserveNewRequestId, validRequestId } from '@zeruxjs/analytics';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { createMemoryTagStore, emptyTagContainer, validateContainer } from './container.js';
|
|
5
|
+
import { browserScript } from './browser.js';
|
|
6
|
+
import { publishedContainer, releaseSummary, saveTagDraft, publishTagDraft, rollbackTagRelease } from './governance.js';
|
|
7
|
+
import { dashboard } from './dashboard.js';
|
|
8
|
+
const nameRe = /^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/;
|
|
9
|
+
const safeName = (name) => typeof name === 'string' && nameRe.test(name);
|
|
10
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } });
|
|
11
|
+
const pathOk = (s) => /^\/[A-Za-z0-9/_-]*$/.test(s) && s !== '/' && !s.includes('//') && !s.endsWith('/');
|
|
12
|
+
const scalar = (v) => v === null || typeof v === 'string' && v.length <= 1024 || typeof v === 'number' && Number.isFinite(v) || typeof v === 'boolean';
|
|
13
|
+
const cleanValues = (values) => {
|
|
14
|
+
if (!values || typeof values !== 'object' || Array.isArray(values) || Object.keys(values).length > 40)
|
|
15
|
+
throw new TypeError('Invalid tag values');
|
|
16
|
+
const result = Object.create(null);
|
|
17
|
+
for (const [k, v] of Object.entries(values)) {
|
|
18
|
+
if (!safeName(k) || ['__proto__', 'constructor', 'prototype', 'zRequestId', 'zRequestUtcDay'].includes(k) || !scalar(v))
|
|
19
|
+
throw new TypeError('Invalid tag values');
|
|
20
|
+
result[k] = v;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
function resolveVariable(variable, ev) {
|
|
25
|
+
if (variable.type === 'constant')
|
|
26
|
+
return variable.value;
|
|
27
|
+
if (!variable.key)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (variable.type === 'event')
|
|
30
|
+
return ev.properties[variable.key];
|
|
31
|
+
if (variable.type === 'dataLayer')
|
|
32
|
+
return ev.data?.[variable.key];
|
|
33
|
+
if (variable.type === 'context') {
|
|
34
|
+
const context = { requestId: ev.requestId, requestUtcDay: ev.requestUtcDay, source: ev.source, site: ev.site, pathname: ev.pathname, sessionId: ev.sessionId, name: ev.name };
|
|
35
|
+
return context[variable.key];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Template parameters reference only registered variables, with no eval or dynamic code. */
|
|
39
|
+
function tagParameters(tag, event, container) {
|
|
40
|
+
const params = Object.create(null);
|
|
41
|
+
for (const [key, value] of Object.entries(tag.parameters ?? {})) {
|
|
42
|
+
if (typeof value === 'string') {
|
|
43
|
+
const match = /^\{\{([A-Za-z][A-Za-z0-9_.:-]{0,95})\}\}$/.exec(value);
|
|
44
|
+
if (match) {
|
|
45
|
+
const variable = container.variables.find(v => v.id === match[1]);
|
|
46
|
+
const resolved = variable ? resolveVariable(variable, event) : undefined;
|
|
47
|
+
if (resolved !== undefined)
|
|
48
|
+
params[key] = resolved;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
params[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return params;
|
|
55
|
+
}
|
|
56
|
+
function matchesCondition(condition, ev, container) {
|
|
57
|
+
const v = container.variables.find(x => x.id === condition.field);
|
|
58
|
+
const actual = v ? resolveVariable(v, ev) : ev.properties[condition.field] ?? ev.data?.[condition.field];
|
|
59
|
+
const expected = condition.value;
|
|
60
|
+
switch (condition.operator) {
|
|
61
|
+
case 'exists': return actual !== undefined && actual !== null;
|
|
62
|
+
case 'eq': return actual === expected;
|
|
63
|
+
case 'neq': return actual !== expected;
|
|
64
|
+
case 'contains': return typeof actual === 'string' && typeof expected === 'string' && actual.includes(expected);
|
|
65
|
+
case 'gte': return typeof actual === 'number' && typeof expected === 'number' && actual >= expected;
|
|
66
|
+
case 'lte': return typeof actual === 'number' && typeof expected === 'number' && actual <= expected;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function createTagManager(options) {
|
|
70
|
+
if (!options?.analytics || !options?.requestIds)
|
|
71
|
+
throw new TypeError('Analytics and request ID store are required');
|
|
72
|
+
const prefix = options.prefix ?? '/_tags';
|
|
73
|
+
if (!pathOk(prefix))
|
|
74
|
+
throw new TypeError('Invalid tag endpoint path');
|
|
75
|
+
if (options.dashboardHost && !/^[A-Za-z0-9.-]+(?::\d{2,5})?$/.test(options.dashboardHost))
|
|
76
|
+
throw new TypeError('Invalid tag dashboard host');
|
|
77
|
+
if (options.publicOrigin) {
|
|
78
|
+
const url = new URL(options.publicOrigin);
|
|
79
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))) || url.pathname !== '/' || url.search || url.hash || url.username || url.password)
|
|
80
|
+
throw new TypeError('Invalid public tagging origin');
|
|
81
|
+
}
|
|
82
|
+
const store = options.store ?? createMemoryTagStore(options.container ?? emptyTagContainer());
|
|
83
|
+
const http = options.fetch ?? fetch;
|
|
84
|
+
const warn = (message) => { try {
|
|
85
|
+
(options.onWarning ?? console.warn)(`[zerux tags] ${message}`);
|
|
86
|
+
}
|
|
87
|
+
catch { /* telemetry warnings cannot block traffic */ } };
|
|
88
|
+
const configuredDestinations = options.destinations ?? {};
|
|
89
|
+
for (const destination of Object.values(configuredDestinations)) {
|
|
90
|
+
const url = new URL(destination.url);
|
|
91
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash || !url.hostname || isIP(url.hostname) !== 0 || url.hostname.startsWith('[') || /^(localhost|.+\.(?:localhost|local|internal))\.?$/i.test(url.hostname))
|
|
92
|
+
throw new TypeError('Webhook destinations must be public HTTPS DNS endpoints');
|
|
93
|
+
}
|
|
94
|
+
const triggerMatches = (type, name, eventName) => type === 'all' ||
|
|
95
|
+
type === 'event' && eventName === name || type === 'pageview' && ['page_view', 'page.view'].includes(name) ||
|
|
96
|
+
type === 'click' && name === 'click' || type === 'form' && name === 'form_submit' || type === 'history' && name === 'history_change';
|
|
97
|
+
async function evaluate(event) {
|
|
98
|
+
let container;
|
|
99
|
+
try {
|
|
100
|
+
container = publishedContainer(await store.load());
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
warn('Published container unavailable');
|
|
104
|
+
return { requestId: event.requestId, requestUtcDay: event.requestUtcDay, matchedTriggers: [], firedTags: [], failures: ['container_unavailable'] };
|
|
105
|
+
}
|
|
106
|
+
const matched = container.triggers.filter(t => t.enabled !== false &&
|
|
107
|
+
triggerMatches(t.type, event.name, t.event) &&
|
|
108
|
+
(t.conditions ?? []).every(condition => matchesCondition(condition, event, container)));
|
|
109
|
+
const ids = new Set(matched.map(t => t.id)), firedTags = [], failures = [];
|
|
110
|
+
for (const tag of container.tags) {
|
|
111
|
+
if (tag.enabled === false || !tag.triggerIds.some(id => ids.has(id)))
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const params = tagParameters(tag, event, container);
|
|
115
|
+
if (tag.type === 'analytics') {
|
|
116
|
+
await options.analytics.track({ name: tag.eventName ?? event.name, source: event.source, site: event.site, pathname: event.pathname, sessionId: event.sessionId, requestId: event.requestId, requestUtcDay: event.requestUtcDay, properties: { ...event.properties, ...params } });
|
|
117
|
+
}
|
|
118
|
+
else if (tag.type === 'ga4') {
|
|
119
|
+
const secret = options.credentials?.[tag.credentialRef ?? '']?.secret;
|
|
120
|
+
if (!secret)
|
|
121
|
+
throw new Error('Missing GA4 credential');
|
|
122
|
+
const variable = container.variables.find(v => v.id === tag.clientIdVariable);
|
|
123
|
+
const clientId = variable ? resolveVariable(variable, event) : event.sessionId;
|
|
124
|
+
if (typeof clientId !== 'string' || !clientId || clientId.length > 128)
|
|
125
|
+
throw new Error('GA4 client_id must be supplied explicitly');
|
|
126
|
+
const url = new URL('https://www.google-analytics.com/mp/collect');
|
|
127
|
+
url.searchParams.set('measurement_id', tag.measurementId);
|
|
128
|
+
url.searchParams.set('api_secret', secret);
|
|
129
|
+
const response = await http(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ client_id: clientId, events: [{ name: tag.eventName ?? event.name, params: { ...event.properties, ...params, zerux_request_id: event.requestId } }] }), signal: AbortSignal.timeout(1000) });
|
|
130
|
+
if (!response.ok)
|
|
131
|
+
throw new Error('GA4 endpoint rejected request');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const destination = configuredDestinations[tag.destinationRef ?? ''];
|
|
135
|
+
if (!destination)
|
|
136
|
+
throw new Error('Missing allowlisted destination');
|
|
137
|
+
const response = await http(destination.url, { method: 'POST', headers: { ...destination.headers, 'content-type': 'application/json' }, body: JSON.stringify({ event: tag.eventName ?? event.name, requestId: event.requestId, utcDay: event.requestUtcDay, site: event.site, properties: event.properties, parameters: params }), signal: AbortSignal.timeout(1000), redirect: 'error' });
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
throw new Error('Destination rejected request');
|
|
140
|
+
}
|
|
141
|
+
firedTags.push(tag.id);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
failures.push(tag.id);
|
|
145
|
+
warn(`Destination failed for tag ${tag.id}`);
|
|
146
|
+
} // Tags are isolated, never block other tags.
|
|
147
|
+
}
|
|
148
|
+
const result = { requestId: event.requestId, requestUtcDay: event.requestUtcDay, matchedTriggers: [...ids], firedTags, failures };
|
|
149
|
+
if (options.debug)
|
|
150
|
+
try {
|
|
151
|
+
options.onDebug?.({ requestId: event.requestId, event: event.name, matchedTriggers: result.matchedTriggers, firedTags, failures });
|
|
152
|
+
}
|
|
153
|
+
catch { /* This optional failure is intentionally ignored. */ }
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
/** Server-side event entry point; request IDs may be shared within a single caller request scope. */
|
|
157
|
+
function createAstag(context = {}) {
|
|
158
|
+
const layer = Object.create(null);
|
|
159
|
+
const conf = { site: context.site ?? options.site, pathname: context.pathname, sessionId: context.sessionId };
|
|
160
|
+
let consent = options.defaultConsent ?? 'granted';
|
|
161
|
+
const reservedDay = context.requestUtcDay ?? requestUtcDay();
|
|
162
|
+
let allocated;
|
|
163
|
+
async function ensureId() {
|
|
164
|
+
if (!allocated) {
|
|
165
|
+
const day = reservedDay;
|
|
166
|
+
allocated = (async () => {
|
|
167
|
+
if (context.requestId) {
|
|
168
|
+
if (!validRequestId(context.requestId))
|
|
169
|
+
throw new TypeError('Invalid request ID');
|
|
170
|
+
if (!await options.requestIds.reserve(day, context.requestId))
|
|
171
|
+
throw new Error('Duplicate request ID');
|
|
172
|
+
return context.requestId;
|
|
173
|
+
}
|
|
174
|
+
return reserveNewRequestId(options.requestIds, day);
|
|
175
|
+
})();
|
|
176
|
+
}
|
|
177
|
+
return allocated;
|
|
178
|
+
}
|
|
179
|
+
return async function astag(command, arg, value) {
|
|
180
|
+
if (command === 'set') {
|
|
181
|
+
if (typeof arg !== 'string' || !safeName(arg) || !scalar(value))
|
|
182
|
+
throw new TypeError('Invalid data-layer value');
|
|
183
|
+
layer[arg] = value;
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (command === 'get')
|
|
187
|
+
return typeof arg === 'string' ? layer[arg] ?? conf[arg] : undefined;
|
|
188
|
+
if (command === 'config') {
|
|
189
|
+
if (typeof arg !== 'object' || arg === null)
|
|
190
|
+
throw new TypeError('Config must be an object');
|
|
191
|
+
for (const [key, item] of Object.entries(arg))
|
|
192
|
+
if (['site', 'pathname', 'sessionId'].includes(key) && typeof item === 'string' && item.length <= 200)
|
|
193
|
+
conf[key] = item;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (command === 'consent') {
|
|
197
|
+
if (arg !== 'granted' && arg !== 'denied')
|
|
198
|
+
throw new TypeError('Invalid telemetry consent state');
|
|
199
|
+
consent = arg;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (command !== 'event' || typeof arg !== 'string' || !safeName(arg))
|
|
203
|
+
throw new TypeError('Invalid tag operation');
|
|
204
|
+
if (consent === 'denied')
|
|
205
|
+
return undefined;
|
|
206
|
+
const properties = cleanValues(value ?? {});
|
|
207
|
+
let id;
|
|
208
|
+
try {
|
|
209
|
+
id = await ensureId();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
allocated = undefined;
|
|
213
|
+
warn('Request ID reservation unavailable');
|
|
214
|
+
return { requestId: '', requestUtcDay: reservedDay, matchedTriggers: [], firedTags: [], failures: ['request_id_unavailable'] };
|
|
215
|
+
}
|
|
216
|
+
return evaluate({ name: arg, properties, source: 'server', requestId: id, requestUtcDay: reservedDay, site: conf.site, pathname: conf.pathname, sessionId: conf.sessionId, data: { ...layer } });
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const collector = createAnalyticsCollector({ analytics: options.analytics, prefix, allowedOrigins: options.allowedOrigins, serverToken: options.serverToken, requestIdStore: options.requestIds, requireRequestId: true,
|
|
220
|
+
siteByOrigin: options.siteByOrigin, requireBrowserConsent: options.requireBrowserConsent ?? options.defaultConsent === 'denied', botPolicy: options.botPolicy,
|
|
221
|
+
maxBodyBytes: options.maxBodyBytes ?? 16_384, maxEventsPerMinute: options.maxEventsPerMinute ?? 120, allowTextPlainBeacon: true,
|
|
222
|
+
dispatch: async (input, ctx) => {
|
|
223
|
+
if (!safeName(input.name))
|
|
224
|
+
return;
|
|
225
|
+
const props = cleanValues(input.properties ?? {}), data = cleanValues(input.data ?? {});
|
|
226
|
+
await evaluate({ name: input.name, properties: props, data, site: input.site ?? options.site, pathname: input.pathname, sessionId: input.sessionId, source: 'web', requestId: ctx.requestId, requestUtcDay: ctx.utcDay });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
const endpoint = options.publicOrigin ? new URL(prefix + '/collect', options.publicOrigin).href : prefix + '/collect';
|
|
230
|
+
const scriptHandler = async (_request) => {
|
|
231
|
+
let activeTypes = [];
|
|
232
|
+
try {
|
|
233
|
+
const container = publishedContainer(await store.load());
|
|
234
|
+
activeTypes = [...new Set(container.triggers.filter(t => t.enabled !== false).map(t => t.type))];
|
|
235
|
+
}
|
|
236
|
+
catch { /* This optional failure is intentionally ignored. */ }
|
|
237
|
+
const js = browserScript({ endpoint, site: options.site ?? '', debug: options.debug === true, autoEvents: activeTypes, defaultConsent: options.defaultConsent ?? 'granted' });
|
|
238
|
+
return new Response(js, { headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'cross-origin-resource-policy': 'cross-origin' } });
|
|
239
|
+
};
|
|
240
|
+
const authorized = async (request) => {
|
|
241
|
+
if (options.dashboardHost && new URL(request.url).host !== options.dashboardHost)
|
|
242
|
+
return false;
|
|
243
|
+
if (!options.authorizeAdmin)
|
|
244
|
+
return false;
|
|
245
|
+
try {
|
|
246
|
+
return await options.authorizeAdmin(request) === true;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const canPublish = async (request) => {
|
|
253
|
+
if (!await authorized(request) || !options.authorizePublish)
|
|
254
|
+
return false;
|
|
255
|
+
try {
|
|
256
|
+
return await options.authorizePublish(request) === true;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const sameOrigin = (request) => request.headers.get('origin') === new URL(request.url).origin;
|
|
263
|
+
const readBody = async (request, limit = 8192) => { const body = await request.text(); if (Buffer.byteLength(body) > limit)
|
|
264
|
+
throw new RangeError('Body too large'); return JSON.parse(body); };
|
|
265
|
+
const admin = async (request) => {
|
|
266
|
+
if (!await authorized(request))
|
|
267
|
+
return new Response('Not found', { status: 404 });
|
|
268
|
+
const html = dashboard(prefix), scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
|
269
|
+
const scriptHashes = scripts.map(match => `'sha256-${createHash('sha256').update(match[1]).digest('base64')}'`).join(' ');
|
|
270
|
+
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': `default-src 'none'; script-src ${scriptHashes}; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'`, 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'x-frame-options': 'DENY' } });
|
|
271
|
+
};
|
|
272
|
+
const preview = async (request) => {
|
|
273
|
+
if (!await authorized(request))
|
|
274
|
+
return json({ error: 'Forbidden' }, 403);
|
|
275
|
+
if (request.method !== 'POST')
|
|
276
|
+
return json({ error: 'Method not allowed' }, 405);
|
|
277
|
+
if (!sameOrigin(request))
|
|
278
|
+
return json({ error: 'Origin required' }, 403);
|
|
279
|
+
try {
|
|
280
|
+
const input = await readBody(request);
|
|
281
|
+
if (input.mode !== undefined && !['draft', 'published'].includes(input.mode))
|
|
282
|
+
return json({ error: 'Invalid mode' }, 422);
|
|
283
|
+
if (!safeName(input.name))
|
|
284
|
+
return json({ error: 'Invalid event name' }, 422);
|
|
285
|
+
const ev = { name: input.name, properties: cleanValues(input.properties ?? {}), data: cleanValues(input.data ?? {}), source: 'web', requestId: 'PREVIEWONLY00000', requestUtcDay: requestUtcDay() };
|
|
286
|
+
const loaded = validateContainer(await store.load());
|
|
287
|
+
const container = input.mode === 'published' ? publishedContainer(loaded) : loaded;
|
|
288
|
+
const matched = container.triggers.filter(t => t.enabled !== false && triggerMatches(t.type, ev.name, t.event) && (t.conditions ?? []).every(c => matchesCondition(c, ev, container)));
|
|
289
|
+
const ids = new Set(matched.map(t => t.id));
|
|
290
|
+
return json({ dryRun: true, mode: input.mode ?? 'draft', event: ev.name, matchedTriggers: [...ids], candidateTags: container.tags.filter(t => t.enabled !== false && t.triggerIds.some(id => ids.has(id))).map(t => ({ id: t.id, type: t.type })) });
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return json({ error: 'Invalid preview' }, 422);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const api = async (request) => {
|
|
297
|
+
if (!await authorized(request))
|
|
298
|
+
return json({ error: 'Forbidden' }, 403);
|
|
299
|
+
if (request.method === 'GET')
|
|
300
|
+
return json(await store.load());
|
|
301
|
+
if (request.method !== 'PUT')
|
|
302
|
+
return json({ error: 'Method not allowed' }, 405);
|
|
303
|
+
if (!sameOrigin(request))
|
|
304
|
+
return json({ error: 'Origin required' }, 403);
|
|
305
|
+
try {
|
|
306
|
+
const parsed = validateContainer(await readBody(request, 128 * 1024));
|
|
307
|
+
const previous = parsed.version - 1;
|
|
308
|
+
if (!await saveTagDraft(store, parsed, previous))
|
|
309
|
+
return json({ error: 'Version conflict' }, 409);
|
|
310
|
+
return json({ ok: true, version: parsed.version, mode: 'draft' });
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return json({ error: 'Invalid container' }, 422);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const status = async (request) => {
|
|
317
|
+
if (!await authorized(request))
|
|
318
|
+
return json({ error: 'Forbidden' }, 403);
|
|
319
|
+
if (request.method !== 'GET')
|
|
320
|
+
return json({ error: 'Method not allowed' }, 405);
|
|
321
|
+
return json(releaseSummary(await store.load()));
|
|
322
|
+
};
|
|
323
|
+
const transition = async (request, action) => {
|
|
324
|
+
if (!await canPublish(request))
|
|
325
|
+
return json({ error: 'Forbidden' }, 403);
|
|
326
|
+
if (request.method !== 'POST')
|
|
327
|
+
return json({ error: 'Method not allowed' }, 405);
|
|
328
|
+
if (!sameOrigin(request))
|
|
329
|
+
return json({ error: 'Origin required' }, 403);
|
|
330
|
+
try {
|
|
331
|
+
const payload = await readBody(request);
|
|
332
|
+
if (!payload || !Number.isSafeInteger(payload.expectedVersion) || payload.expectedVersion < 1 ||
|
|
333
|
+
action === 'rollback' && (!Number.isSafeInteger(payload.releaseId) || payload.releaseId < 1))
|
|
334
|
+
return json({ error: 'Invalid transition' }, 422);
|
|
335
|
+
if (action === 'publish') {
|
|
336
|
+
const candidate = validateContainer(await store.load());
|
|
337
|
+
if (candidate.version !== payload.expectedVersion)
|
|
338
|
+
return json({ error: 'Version conflict' }, 409);
|
|
339
|
+
if (candidate.tags.some(tag => tag.enabled !== false && (tag.type === 'ga4' && !options.credentials?.[tag.credentialRef ?? '']?.secret ||
|
|
340
|
+
tag.type === 'webhook' && !configuredDestinations[tag.destinationRef ?? ''])))
|
|
341
|
+
return json({ error: 'Unconfigured destination' }, 422);
|
|
342
|
+
}
|
|
343
|
+
const success = action === 'publish' ? await publishTagDraft(store, payload.expectedVersion) :
|
|
344
|
+
await rollbackTagRelease(store, payload.expectedVersion, payload.releaseId);
|
|
345
|
+
if (!success)
|
|
346
|
+
return json({ error: 'Version conflict' }, 409);
|
|
347
|
+
return json({ ok: true, ...releaseSummary(await store.load()) });
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
return json({ error: error instanceof RangeError ? 'Release unavailable' : 'Invalid transition' }, 422);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
createAstag, evaluate, container: () => store.load(), published: async () => publishedContainer(await store.load()), store,
|
|
355
|
+
routes() { return [{ pattern: collector.path, handler: collector.handler }, { pattern: `${prefix}/ttag.js`, handler: scriptHandler }, ...(options.authorizeAdmin ? [{ pattern: `${prefix}/admin`, handler: admin }, { pattern: `${prefix}/admin/api`, handler: api }, { pattern: `${prefix}/admin/preview`, handler: preview }, { pattern: `${prefix}/admin/status`, handler: status }, { pattern: `${prefix}/admin/publish`, handler: (request) => transition(request, 'publish') }, { pattern: `${prefix}/admin/rollback`, handler: (request) => transition(request, 'rollback') }] : [])]; }
|
|
356
|
+
};
|
|
357
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { AnalyticsManager } from '@zeruxjs/analytics';
|
|
2
|
+
import type { RequestIdStore } from '@zeruxjs/analytics';
|
|
3
|
+
export type Scalar = string | number | boolean | null;
|
|
4
|
+
export type Values = Record<string, Scalar>;
|
|
5
|
+
export interface TagEvent {
|
|
6
|
+
name: string;
|
|
7
|
+
properties: Values;
|
|
8
|
+
site?: string;
|
|
9
|
+
pathname?: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
requestId: string;
|
|
12
|
+
requestUtcDay: string;
|
|
13
|
+
source: 'web' | 'server';
|
|
14
|
+
data?: Values;
|
|
15
|
+
}
|
|
16
|
+
export interface Condition {
|
|
17
|
+
field: string;
|
|
18
|
+
operator: 'eq' | 'neq' | 'contains' | 'exists' | 'gte' | 'lte';
|
|
19
|
+
value?: Scalar;
|
|
20
|
+
}
|
|
21
|
+
export interface Trigger {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
type: 'event' | 'all' | 'pageview' | 'click' | 'form' | 'history';
|
|
25
|
+
event?: string;
|
|
26
|
+
conditions?: Condition[];
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface Variable {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
type: 'constant' | 'event' | 'dataLayer' | 'context';
|
|
33
|
+
key?: string;
|
|
34
|
+
value?: Scalar;
|
|
35
|
+
}
|
|
36
|
+
export interface TagDefinition {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
type: 'analytics' | 'ga4' | 'webhook';
|
|
40
|
+
triggerIds: string[];
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
eventName?: string;
|
|
43
|
+
measurementId?: string;
|
|
44
|
+
credentialRef?: string;
|
|
45
|
+
destinationRef?: string;
|
|
46
|
+
clientIdVariable?: string;
|
|
47
|
+
parameters?: Record<string, Scalar | string>;
|
|
48
|
+
}
|
|
49
|
+
/** A historical release is immutable; revisions (container.version) advance on every edit. */
|
|
50
|
+
export interface TagRelease {
|
|
51
|
+
id: number;
|
|
52
|
+
at: string;
|
|
53
|
+
action: 'initial' | 'publish' | 'rollback';
|
|
54
|
+
container: TagContainer;
|
|
55
|
+
}
|
|
56
|
+
export interface TagGovernance {
|
|
57
|
+
release: number;
|
|
58
|
+
published: TagContainer;
|
|
59
|
+
history: TagRelease[];
|
|
60
|
+
}
|
|
61
|
+
export interface TagContainer {
|
|
62
|
+
id: string;
|
|
63
|
+
version: number;
|
|
64
|
+
name: string;
|
|
65
|
+
triggers: Trigger[];
|
|
66
|
+
tags: TagDefinition[];
|
|
67
|
+
variables: Variable[];
|
|
68
|
+
governance?: TagGovernance;
|
|
69
|
+
}
|
|
70
|
+
export interface TagContainerStore {
|
|
71
|
+
load(): Promise<TagContainer>;
|
|
72
|
+
save(container: TagContainer, expectedVersion: number): Promise<boolean>;
|
|
73
|
+
}
|
|
74
|
+
export interface TagRuntimeOptions {
|
|
75
|
+
analytics: AnalyticsManager;
|
|
76
|
+
/** A shared atomic store is REQUIRED for cross-instance request-ID uniqueness. */
|
|
77
|
+
requestIds: RequestIdStore;
|
|
78
|
+
store?: TagContainerStore;
|
|
79
|
+
container?: TagContainer;
|
|
80
|
+
prefix?: string;
|
|
81
|
+
/** Absolute public origin for a separate tagging subdomain; browser sends here. */
|
|
82
|
+
publicOrigin?: string;
|
|
83
|
+
site?: string;
|
|
84
|
+
allowedOrigins?: string[];
|
|
85
|
+
serverToken?: string;
|
|
86
|
+
/** Secrets resolved server-side; never included in an exposed container. */
|
|
87
|
+
credentials?: Record<string, {
|
|
88
|
+
secret: string;
|
|
89
|
+
}>;
|
|
90
|
+
destinations?: Record<string, {
|
|
91
|
+
url: string;
|
|
92
|
+
headers?: Record<string, string>;
|
|
93
|
+
}>;
|
|
94
|
+
fetch?: typeof fetch;
|
|
95
|
+
/** No admin routes are created unless an application authorizer is supplied. */
|
|
96
|
+
authorizeAdmin?: (request: Request) => boolean | Promise<boolean>;
|
|
97
|
+
/** An independent permission check; without this callback no one may publish or roll back. */
|
|
98
|
+
authorizePublish?: (request: Request) => boolean | Promise<boolean>;
|
|
99
|
+
/** Browser event consent is a client assertion; still enforce consent at collection ingress. */
|
|
100
|
+
requireBrowserConsent?: boolean;
|
|
101
|
+
/** Bind approved origins to fixed site identities; never trust browser-supplied site names. */
|
|
102
|
+
siteByOrigin?: Record<string, string>;
|
|
103
|
+
botPolicy?: 'allow' | 'drop-known';
|
|
104
|
+
dashboardHost?: string;
|
|
105
|
+
debug?: boolean;
|
|
106
|
+
/** Initial telemetry setting, independent of email consent. */
|
|
107
|
+
defaultConsent?: 'granted' | 'denied';
|
|
108
|
+
onDebug?: (data: {
|
|
109
|
+
requestId: string;
|
|
110
|
+
event: string;
|
|
111
|
+
matchedTriggers: string[];
|
|
112
|
+
firedTags: string[];
|
|
113
|
+
failures: string[];
|
|
114
|
+
}) => void;
|
|
115
|
+
/** Fail-soft sink, store and ID warnings. Never receives credentials, URLs or event payloads. */
|
|
116
|
+
onWarning?: (message: string) => void;
|
|
117
|
+
maxBodyBytes?: number;
|
|
118
|
+
maxEventsPerMinute?: number;
|
|
119
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeruxjs/tags",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Server and browser tag manager with versioned containers, triggers and destinations",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
17
|
+
"prepack": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@zeruxjs/analytics": "0.0.1"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE-MIT",
|
|
26
|
+
"LICENSE-APACHE"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"license": "(MIT OR Apache-2.0)",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.9.3",
|
|
34
|
+
"@types/node": "^25.4.0"
|
|
35
|
+
}
|
|
36
|
+
}
|