@owlmeans/server-marketing-consent 0.1.18-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +15 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/server-marketing-consent/SKILL.md +148 -0
  4. package/build/consts.d.ts +12 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +12 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/handlers.d.ts +20 -0
  9. package/build/handlers.d.ts.map +1 -0
  10. package/build/handlers.js +23 -0
  11. package/build/handlers.js.map +1 -0
  12. package/build/helper.d.ts +19 -0
  13. package/build/helper.d.ts.map +1 -0
  14. package/build/helper.js +14 -0
  15. package/build/helper.js.map +1 -0
  16. package/build/index.d.ts +7 -0
  17. package/build/index.d.ts.map +1 -0
  18. package/build/index.js +7 -0
  19. package/build/index.js.map +1 -0
  20. package/build/model.d.ts +64 -0
  21. package/build/model.d.ts.map +1 -0
  22. package/build/model.js +83 -0
  23. package/build/model.js.map +1 -0
  24. package/build/service.d.ts +56 -0
  25. package/build/service.d.ts.map +1 -0
  26. package/build/service.js +146 -0
  27. package/build/service.js.map +1 -0
  28. package/build/subject.d.ts +21 -0
  29. package/build/subject.d.ts.map +1 -0
  30. package/build/subject.js +21 -0
  31. package/build/subject.js.map +1 -0
  32. package/package.json +54 -0
  33. package/src/consts.ts +11 -0
  34. package/src/handlers.ts +44 -0
  35. package/src/helper.ts +18 -0
  36. package/src/index.ts +6 -0
  37. package/src/model.ts +147 -0
  38. package/src/service.ts +226 -0
  39. package/src/subject.ts +32 -0
  40. package/tests/context.ts +52 -0
  41. package/tests/handlers.spec.ts +85 -0
  42. package/tests/service.spec.ts +164 -0
  43. package/tests/subject.spec.ts +38 -0
  44. package/tests/tsconfig.json +12 -0
  45. package/tsconfig.json +11 -0
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createLazyService } from '@owlmeans/context';
3
+ import { consentStatus, MARKETING_CONSENT_SERVICE, resolveMarketingConsents, UnknownMarketingConsentError, } from '@owlmeans/marketing-consent';
4
+ import { RecordExists } from '@owlmeans/resource';
5
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from './consts.js';
6
+ import { subjectKey } from './subject.js';
7
+ const nowIso = () => new Date().toISOString();
8
+ /**
9
+ * Build the `MarketingConsentService`. Database-agnostic: both resources are resolved BY ALIAS
10
+ * from the context lazily, inside each method call — never imported or constructed here. A
11
+ * Mongo/Postgres extension package registers the actual resources at those aliases separately.
12
+ */
13
+ export const makeMarketingConsentService = (opts = {}) => {
14
+ const alias = opts.alias ?? MARKETING_CONSENT_SERVICE;
15
+ const stateAlias = opts.state ?? RES_MARKETING_CONSENT_STATE;
16
+ const logAlias = opts.log ?? RES_MARKETING_CONSENT_LOG;
17
+ let memoizedDefinitions;
18
+ const definitions = () => {
19
+ memoizedDefinitions ??= resolveMarketingConsents(opts.config);
20
+ return memoizedDefinitions;
21
+ };
22
+ const listeners = [];
23
+ const ctx = () => service.assertCtx();
24
+ const states = () => ctx().resource(stateAlias);
25
+ const logs = () => ctx().resource(logAlias);
26
+ const appendLog = async (record) => {
27
+ await logs().create({ ...record, id: `log:${record.subject}:${randomUUID()}` });
28
+ };
29
+ const notify = async (subject, decisions) => {
30
+ for (const listener of listeners) {
31
+ try {
32
+ await listener({ subject, decisions });
33
+ }
34
+ catch (error) {
35
+ // A listener's failure never fails the write that triggered it.
36
+ console.error(`${alias}: observer failed for ${subjectKey(subject)}`, error);
37
+ }
38
+ }
39
+ };
40
+ const statusOf = (state, gpc) => consentStatus(definitions(), state?.decisions ?? [], {
41
+ gpc, termsAcceptedAt: state?.terms?.acceptedAt, termsVersion: state?.terms?.version,
42
+ });
43
+ /** Create-then-fall-back-to-update-on-`RecordExists`, the idiom `server-auth-session` uses for
44
+ * the same create-vs-update race on a natural-id resource. */
45
+ const upsertState = async (id, patch) => {
46
+ const existing = await states().load(id);
47
+ if (existing != null) {
48
+ return states().save({ ...existing, ...patch(existing) });
49
+ }
50
+ try {
51
+ return await states().create({ id, ...patch(null) });
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof RecordExists))
55
+ throw error;
56
+ const raced = await states().get(id);
57
+ return states().save({ ...raced, ...patch(raced) });
58
+ }
59
+ };
60
+ const service = createLazyService(alias, {
61
+ definitions,
62
+ status: async (subject, statusOpts) => {
63
+ const state = await states().load(subjectKey(subject));
64
+ return statusOf(state, statusOpts?.gpc);
65
+ },
66
+ save: async (subject, request) => {
67
+ const known = new Set(definitions().map(def => def.key));
68
+ const unknown = request.decisions.find(decision => !known.has(decision.key));
69
+ if (unknown != null) {
70
+ throw new UnknownMarketingConsentError(unknown.key);
71
+ }
72
+ const now = nowIso();
73
+ const decided = request.decisions.map(decision => {
74
+ // Safe: every key was just checked against `known` above.
75
+ const definition = definitions().find(def => def.key === decision.key);
76
+ return {
77
+ key: decision.key, granted: decision.granted, revisedAt: definition.revisedAt,
78
+ mode: definition.mode, decidedAt: now, source: request.source,
79
+ };
80
+ });
81
+ for (const decision of decided) {
82
+ await appendLog({
83
+ subject: subjectKey(subject), userId: subject.userId, profileId: subject.profileId,
84
+ entityId: subject.entityId, kind: 'consent', key: decision.key, granted: decision.granted,
85
+ revisedAt: decision.revisedAt, mode: decision.mode, decidedAt: decision.decidedAt,
86
+ source: decision.source, locale: request.locale, gpc: request.gpc,
87
+ });
88
+ }
89
+ const id = subjectKey(subject);
90
+ const saved = await upsertState(id, existing => {
91
+ const merged = new Map((existing?.decisions ?? []).map(decision => [decision.key, decision]));
92
+ decided.forEach(decision => merged.set(decision.key, decision));
93
+ return {
94
+ subject: id, userId: subject.userId, profileId: subject.profileId, entityId: subject.entityId,
95
+ decisions: [...merged.values()], gpc: request.gpc ?? existing?.gpc,
96
+ createdAt: existing?.createdAt ?? now, updatedAt: now,
97
+ };
98
+ });
99
+ await notify(subject, decided);
100
+ return { ok: true, status: statusOf(saved, request.gpc) };
101
+ },
102
+ recordTerms: async (subject, acceptance, recordOpts) => {
103
+ const now = nowIso();
104
+ const source = recordOpts?.source ?? 'sign-in';
105
+ await appendLog({
106
+ subject: subjectKey(subject), userId: subject.userId, profileId: subject.profileId,
107
+ entityId: subject.entityId, kind: 'terms', documents: acceptance.documents,
108
+ notices: acceptance.notices, version: acceptance.version, decidedAt: now, source,
109
+ locale: acceptance.locale,
110
+ });
111
+ const id = subjectKey(subject);
112
+ const terms = {
113
+ documents: acceptance.documents, notices: acceptance.notices, version: acceptance.version,
114
+ locale: acceptance.locale, acceptedAt: now,
115
+ };
116
+ await upsertState(id, existing => ({
117
+ subject: id, userId: subject.userId, profileId: subject.profileId, entityId: subject.entityId,
118
+ decisions: existing?.decisions ?? [], terms,
119
+ createdAt: existing?.createdAt ?? now, updatedAt: now,
120
+ }));
121
+ return { ok: true };
122
+ },
123
+ isGranted: async (subject, key, grantOpts) => {
124
+ const view = await service.status(subject, grantOpts);
125
+ const item = view.items.find(candidate => candidate.definition.key === key);
126
+ if (item == null || item.status !== 'current')
127
+ return false;
128
+ return item.granted;
129
+ },
130
+ purge: async (subject) => {
131
+ await states().delete(subjectKey(subject));
132
+ },
133
+ observe: listener => {
134
+ listeners.push(listener);
135
+ },
136
+ });
137
+ return service;
138
+ };
139
+ /** Register the service, unless the application already registered its own under this alias. */
140
+ export const appendMarketingConsentService = (context, opts = {}) => {
141
+ const alias = opts.alias ?? MARKETING_CONSENT_SERVICE;
142
+ if (!context.hasService(alias)) {
143
+ context.registerService(makeMarketingConsentService(opts));
144
+ }
145
+ };
146
+ //# sourceMappingURL=service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,EACL,aAAa,EAAE,yBAAyB,EAAE,wBAAwB,EAAE,4BAA4B,GACjG,MAAM,6BAA6B,CAAA;AAKpC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAGjD,OAAO,EAAE,yBAAyB,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AAEpF,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAwCzC,MAAM,MAAM,GAAG,GAAW,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;AAErD;;;;GAIG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CACzC,IAAI,GAAuC,EAAE,EACpB,EAAE;IAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,yBAAyB,CAAA;IACrD,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,IAAI,2BAA2B,CAAA;IAC5D,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,IAAI,yBAAyB,CAAA;IAEtD,IAAI,mBAA6D,CAAA;IACjE,MAAM,WAAW,GAAG,GAAiC,EAAE;QACrD,mBAAmB,KAAK,wBAAwB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC7D,OAAO,mBAAmB,CAAA;IAC5B,CAAC,CAAA;IAED,MAAM,SAAS,GAA+B,EAAE,CAAA;IAEhD,MAAM,GAAG,GAAG,GAA4B,EAAE,CAAC,OAAO,CAAC,SAAS,EAAyC,CAAA;IACrG,MAAM,MAAM,GAAG,GAA0C,EAAE,CACzD,GAAG,EAAE,CAAC,QAAQ,CAAwC,UAAU,CAAC,CAAA;IACnE,MAAM,IAAI,GAAG,GAAwC,EAAE,CACrD,GAAG,EAAE,CAAC,QAAQ,CAAsC,QAAQ,CAAC,CAAA;IAE/D,MAAM,SAAS,GAAG,KAAK,EAAE,MAA6C,EAAiB,EAAE;QACvF,MAAM,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,MAAM,EAAE,EAAE,EAAE,OAAO,MAAM,CAAC,OAAO,IAAI,UAAU,EAAE,EAAE,EAAE,CAAC,CAAA;IACjF,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,KAAK,EAAE,OAAgC,EAAE,SAAqC,EAAiB,EAAE;QAC9G,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;YACjC,IAAI,CAAC;gBACH,MAAM,QAAQ,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;YACxC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,gEAAgE;gBAChE,OAAO,CAAC,KAAK,CAAC,GAAG,KAAK,yBAAyB,UAAU,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,CAAC,CAAA;YAC9E,CAAC;QACH,CAAC;IACH,CAAC,CAAA;IAED,MAAM,QAAQ,GAAG,CACf,KAAyC,EAAE,GAAa,EAC5B,EAAE,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,KAAK,EAAE,SAAS,IAAI,EAAE,EAAE;QACpF,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO;KACpF,CAAC,CAAA;IAEF;kEAC8D;IAC9D,MAAM,WAAW,GAAG,KAAK,EACvB,EAAU,EAAE,KAA6F,EACnE,EAAE;QACxC,MAAM,QAAQ,GAAG,MAAM,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACxC,IAAI,QAAQ,IAAI,IAAI,EAAE,CAAC;YACrB,OAAO,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,GAAG,QAAQ,EAAE,GAAG,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAA;QAC3D,CAAC;QACD,IAAI,CAAC;YACH,OAAO,MAAM,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACtD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,CAAC,CAAC,KAAK,YAAY,YAAY,CAAC;gBAAE,MAAM,KAAK,CAAA;YACjD,MAAM,KAAK,GAAG,MAAM,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;YACpC,OAAO,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;QACrD,CAAC;IACH,CAAC,CAAA;IAED,MAAM,OAAO,GAA4B,iBAAiB,CAA0B,KAAK,EAAE;QACzF,WAAW;QAEX,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE;YACpC,MAAM,KAAK,GAAG,MAAM,MAAM,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAA;YACtD,OAAO,QAAQ,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,CAAC,CAAA;QACzC,CAAC;QAED,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE;YAC/B,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAA;YACxD,MAAM,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAA;YAC5E,IAAI,OAAO,IAAI,IAAI,EAAE,CAAC;gBACpB,MAAM,IAAI,4BAA4B,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;YACrD,CAAC;YAED,MAAM,GAAG,GAAG,MAAM,EAAE,CAAA;YACpB,MAAM,OAAO,GAA+B,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;gBAC3E,0DAA0D;gBAC1D,MAAM,UAAU,GAAG,WAAW,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,QAAQ,CAAC,GAAG,CAAE,CAAA;gBACvE,OAAO;oBACL,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,SAAS,EAAE,UAAU,CAAC,SAAS;oBAC7E,IAAI,EAAE,UAAU,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM;iBAC9D,CAAA;YACH,CAAC,CAAC,CAAA;YAEF,KAAK,MAAM,QAAQ,IAAI,OAAO,EAAE,CAAC;gBAC/B,MAAM,SAAS,CAAC;oBACd,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS;oBAClF,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO;oBACzF,SAAS,EAAE,QAAQ,CAAC,SAAS,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,CAAC,SAAS;oBACjF,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG;iBAClE,CAAC,CAAA;YACJ,CAAC;YAED,MAAM,EAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAAA;YAC9B,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,EAAE,EAAE,QAAQ,CAAC,EAAE;gBAC7C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;gBAC7F,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAA;gBAC/D,OAAO;oBACL,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ;oBAC7F,SAAS,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,IAAI,QAAQ,EAAE,GAAG;oBAClE,SAAS,EAAE,QAAQ,EAAE,SAAS,IAAI,GAAG,EAAE,SAAS,EAAE,GAAG;iBACtD,CAAA;YACH,CAAC,CAAC,CAAA;YAEF,MAAM,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;YAE9B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,CAAA;QAC3D,CAAC;QAED,WAAW,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,EAAE;YACrD,MAAM,GAAG,GAAG,MAAM,EAAE,CAAA;YACpB,MAAM,MAAM,GAAG,UAAU,EAAE,MAAM,IAAI,SAAS,CAAA;YAE9C,MAAM,SAAS,CAAC;gBACd,OAAO,EAAE,UAAU,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS;gBAClF,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,CAAC,SAAS;gBAC1E,OAAO,EAAE,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM;gBAChF,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAA;YAEF,MAAM,EAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAAA;YAC9B,MAAM,KAAK,GAAG;gBACZ,SAAS,EAAE,UAAU,CAAC,SAAS,EAAE,OAAO,EAAE,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,CAAC,OAAO;gBACzF,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG;aAC3C,CAAA;YACD,MAAM,WAAW,CAAC,EAAE,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;gBACjC,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ;gBAC7F,SAAS,EAAE,QAAQ,EAAE,SAAS,IAAI,EAAE,EAAE,KAAK;gBAC3C,SAAS,EAAE,QAAQ,EAAE,SAAS,IAAI,GAAG,EAAE,SAAS,EAAE,GAAG;aACtD,CAAC,CAAC,CAAA;YAEH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;QACrB,CAAC;QAED,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE;YAC3C,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,CAAA;YACrD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC,GAAG,KAAK,GAAG,CAAC,CAAA;YAC3E,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;gBAAE,OAAO,KAAK,CAAA;YAE3D,OAAO,IAAI,CAAC,OAAO,CAAA;QACrB,CAAC;QAED,KAAK,EAAE,KAAK,EAAC,OAAO,EAAC,EAAE;YACrB,MAAM,MAAM,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAA;QAC5C,CAAC;QAED,OAAO,EAAE,QAAQ,CAAC,EAAE;YAClB,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC1B,CAAC;KACF,CAAC,CAAA;IAEF,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED,gGAAgG;AAChG,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAC3C,OAAgC,EAAE,IAAI,GAAuC,EAAE,EACzE,EAAE;IACR,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,yBAAyB,CAAA;IACrD,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;QAC/B,OAAO,CAAC,eAAe,CAAC,2BAA2B,CAAC,IAAI,CAAC,CAAC,CAAA;IAC5D,CAAC;AACH,CAAC,CAAA"}
@@ -0,0 +1,21 @@
1
+ import type { AbstractRequest } from '@owlmeans/entrypoint';
2
+ /** Who a consent decision or a terms acceptance is recorded for. */
3
+ export interface MarketingConsentSubject {
4
+ userId: string;
5
+ profileId?: string;
6
+ entityId?: string;
7
+ }
8
+ /**
9
+ * Read the subject off an authenticated request.
10
+ *
11
+ * `entityId` is read ONLY from `req.entity?.id` — never `requireEntityKey`, never a slug. A
12
+ * deployment with no organization concept at all (an end-user-facing generated target app, say)
13
+ * registers no entity resolver, so `req.entity` stays `undefined` and `entityId` is simply absent
14
+ * here — that is a valid, expected shape, not an error.
15
+ *
16
+ * @throws {AuthForbidden} when the request carries no authenticated user.
17
+ */
18
+ export declare const subjectOf: (req: AbstractRequest) => MarketingConsentSubject;
19
+ /** One string key per subject — the state record's own `id`. */
20
+ export declare const subjectKey: (subject: MarketingConsentSubject) => string;
21
+ //# sourceMappingURL=subject.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subject.d.ts","sourceRoot":"","sources":["../src/subject.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAE3D,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,QAAS,eAAe,KAAG,uBAOhD,CAAA;AAED,gEAAgE;AAChE,eAAO,MAAM,UAAU,YAAa,uBAAuB,KAAG,MACY,CAAA"}
@@ -0,0 +1,21 @@
1
+ import { AuthForbidden } from '@owlmeans/auth';
2
+ /**
3
+ * Read the subject off an authenticated request.
4
+ *
5
+ * `entityId` is read ONLY from `req.entity?.id` — never `requireEntityKey`, never a slug. A
6
+ * deployment with no organization concept at all (an end-user-facing generated target app, say)
7
+ * registers no entity resolver, so `req.entity` stays `undefined` and `entityId` is simply absent
8
+ * here — that is a valid, expected shape, not an error.
9
+ *
10
+ * @throws {AuthForbidden} when the request carries no authenticated user.
11
+ */
12
+ export const subjectOf = (req) => {
13
+ const userId = req.auth?.userId;
14
+ if (userId == null || userId === '') {
15
+ throw new AuthForbidden('marketing-consent: no authenticated user');
16
+ }
17
+ return { userId, profileId: req.auth?.profileId, entityId: req.entity?.id };
18
+ };
19
+ /** One string key per subject — the state record's own `id`. */
20
+ export const subjectKey = (subject) => `${subject.entityId ?? ''}|${subject.userId}|${subject.profileId ?? ''}`;
21
+ //# sourceMappingURL=subject.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subject.js","sourceRoot":"","sources":["../src/subject.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAU9C;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,GAAoB,EAA2B,EAAE;IACzE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,MAAM,CAAA;IAC/B,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;QACpC,MAAM,IAAI,aAAa,CAAC,0CAA0C,CAAC,CAAA;IACrE,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,EAAE,CAAA;AAC7E,CAAC,CAAA;AAED,gEAAgE;AAChE,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,OAAgC,EAAU,EAAE,CACrE,GAAG,OAAO,CAAC,QAAQ,IAAI,EAAE,IAAI,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,SAAS,IAAI,EAAE,EAAE,CAAA"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@owlmeans/server-marketing-consent",
3
+ "version": "0.1.18-rc.0",
4
+ "license": "MIT",
5
+ "description": "The server half of @owlmeans/marketing-consent — a database-agnostic MarketingConsentService (status/save/terms/purge over resources resolved by alias) plus the guarded status/save/terms entrypoint handlers.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc -b",
9
+ "dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
10
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
11
+ "test": "bun test ./tests"
12
+ },
13
+ "main": "build/index.js",
14
+ "module": "build/index.js",
15
+ "types": "build/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./build/index.js",
19
+ "require": "./build/index.js",
20
+ "default": "./build/index.js",
21
+ "module": "./build/index.js",
22
+ "types": "./build/index.d.ts"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@owlmeans/auth": "^0.1.18-rc.31",
27
+ "@owlmeans/auth-common": "^0.1.18-rc.33",
28
+ "@owlmeans/context": "^0.1.18-rc.29",
29
+ "@owlmeans/entrypoint": "^0.1.18-rc.32",
30
+ "@owlmeans/error": "^0.1.18-rc.31",
31
+ "@owlmeans/marketing-consent": "^0.1.18-rc.0",
32
+ "@owlmeans/resource": "^0.1.18-rc.31",
33
+ "@owlmeans/server-api": "^0.1.18-rc.37",
34
+ "@owlmeans/server-auth-token": "^0.1.18-rc.18",
35
+ "@owlmeans/server-context": "^0.1.18-rc.32",
36
+ "@owlmeans/server-entrypoint": "^0.1.18-rc.31"
37
+ },
38
+ "peerDependencies": {
39
+ "ajv": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@owlmeans/dep-config": "workspace:*",
43
+ "@owlmeans/static-resource": "^0.1.18-rc.29",
44
+ "@owlmeans/test": "^0.1.18-rc.29",
45
+ "@owlmeans/test-auth": "^0.1.18-rc.32",
46
+ "@types/bun": "^1.4.0",
47
+ "ajv": "^8.17.1",
48
+ "nodemon": "^3.1.14",
49
+ "typescript": "^7.0.2"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The two resource aliases a Mongo/Postgres extension package (`@owlmeans/marketing-consent-mongo`,
3
+ * `@owlmeans/marketing-consent-postgres` — later workstreams, not built yet) registers its concrete
4
+ * resources under.
5
+ *
6
+ * These string values matter beyond this package: a target project's generated resource FILE names
7
+ * are derived from an alias via a deterministic `resourceAlias()` helper elsewhere in the monorepo
8
+ * (`entity/type` -> `entity-type`). Keep them exactly as declared here.
9
+ */
10
+ export const RES_MARKETING_CONSENT_STATE = 'marketing-consent-state'
11
+ export const RES_MARKETING_CONSENT_LOG = 'marketing-consent-log'
@@ -0,0 +1,44 @@
1
+ import { MARKETING_CONSENT_SERVICE } from '@owlmeans/marketing-consent'
2
+ import type { MarketingConsentEntrypoints } from '@owlmeans/marketing-consent'
3
+ import { handlers } from '@owlmeans/server-api'
4
+ import { refuseTokenAuth } from '@owlmeans/server-auth-token'
5
+ import type { MarketingConsentContext, MarketingConsentService } from './service.js'
6
+ import { subjectOf } from './subject.js'
7
+
8
+ export interface MarketingConsentHandlerOptions {
9
+ serviceAlias?: string
10
+ /** Let an OAuth-minted access token save decisions or record terms too. Defaults to `false` —
11
+ * `status` is never token-refused regardless of this option. */
12
+ allowAccessTokens?: boolean
13
+ }
14
+
15
+ const serviceOf = (context: MarketingConsentContext, opts?: MarketingConsentHandlerOptions): MarketingConsentService =>
16
+ context.service<MarketingConsentService>(opts?.serviceAlias ?? MARKETING_CONSENT_SERVICE)
17
+
18
+ /** GPC ("Sec-GPC: 1") read as a plain header — Fastify normalizes header names to lower case, and
19
+ * a repeated header arrives as an array, so only its first value is read. */
20
+ const gpcOf = (headers: Record<string, string[] | string | undefined>): boolean => {
21
+ const raw = headers['sec-gpc']
22
+ return (Array.isArray(raw) ? raw[0] : raw) === '1'
23
+ }
24
+
25
+ export const marketingConsentStatus = (
26
+ protocol: MarketingConsentEntrypoints['status'], opts: MarketingConsentHandlerOptions = {},
27
+ ) => handlers<MarketingConsentContext>().request(protocol, async (req, context) =>
28
+ serviceOf(context, opts).status(subjectOf(req), { gpc: gpcOf(req.headers ?? {}) }))
29
+
30
+ export const saveMarketingConsent = (
31
+ protocol: MarketingConsentEntrypoints['save'], opts: MarketingConsentHandlerOptions = {},
32
+ ) => handlers<MarketingConsentContext>().body(protocol, async (payload, context, req) => {
33
+ if (opts.allowAccessTokens !== true) refuseTokenAuth(req, 'marketing-consent-save')
34
+
35
+ return serviceOf(context, opts).save(subjectOf(req), payload)
36
+ })
37
+
38
+ export const recordTermsAcceptance = (
39
+ protocol: MarketingConsentEntrypoints['terms'], opts: MarketingConsentHandlerOptions = {},
40
+ ) => handlers<MarketingConsentContext>().body(protocol, async (payload, context, req) => {
41
+ if (opts.allowAccessTokens !== true) refuseTokenAuth(req, 'marketing-consent-terms')
42
+
43
+ return serviceOf(context, opts).recordTerms(subjectOf(req), payload)
44
+ })
package/src/helper.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { bind } from '@owlmeans/server-entrypoint'
2
+ import type { MarketingConsentEntrypoints } from '@owlmeans/marketing-consent'
3
+ import { marketingConsentStatus, recordTermsAcceptance, saveMarketingConsent } from './handlers.js'
4
+ import type { MarketingConsentHandlerOptions } from './handlers.js'
5
+
6
+ /**
7
+ * Bind this package's handlers to a tree declared by `makeMarketingConsentProtocols` — one binding
8
+ * per protocol that needs a server implementation. `base` and `screen` carry no handler of their
9
+ * own (a mounting anchor and a frontend address, the same shape as `@owlmeans/oauth`'s own consent
10
+ * screens) and are left for the application's route tree to materialize.
11
+ */
12
+ export const serveMarketingConsentEntrypoints = (
13
+ protocols: MarketingConsentEntrypoints, opts: MarketingConsentHandlerOptions = {},
14
+ ) => [
15
+ bind(protocols.status, marketingConsentStatus(protocols.status, opts)),
16
+ bind(protocols.save, saveMarketingConsent(protocols.save, opts)),
17
+ bind(protocols.terms, recordTermsAcceptance(protocols.terms, opts)),
18
+ ]
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './consts.js'
2
+ export * from './model.js'
3
+ export * from './subject.js'
4
+ export * from './service.js'
5
+ export * from './handlers.js'
6
+ export * from './helper.js'
package/src/model.ts ADDED
@@ -0,0 +1,147 @@
1
+ import type { JSONSchemaType } from 'ajv'
2
+ import type { ResourceRecord } from '@owlmeans/resource'
3
+ import type {
4
+ MarketingConsentDecision, MarketingConsentMode, MarketingConsentSource, TermsDocumentRef,
5
+ } from '@owlmeans/marketing-consent'
6
+
7
+ /**
8
+ * One subject's saved decisions, one record per subject (`id` = `subjectKey(subject)`).
9
+ *
10
+ * `decisions` is an ARRAY, never an object keyed by consent key. A dotted key such as
11
+ * `"marketing.email"` is read as a PATH by both Mongo dot-notation queries and Postgres jsonb path
12
+ * operators — an object-keyed shape breaks the moment a second consent key is added. This is the
13
+ * single most important shape decision in this package; the Mongo/Postgres extensions that will
14
+ * store this record must not "flatten" it into an object for convenience.
15
+ */
16
+ export interface MarketingConsentStateRecord extends ResourceRecord {
17
+ id: string
18
+ /** `subjectKey(subjectOf(req))` — see `./subject.js`. Also this record's own `id`. */
19
+ subject: string
20
+ userId: string
21
+ profileId?: string
22
+ entityId?: string
23
+ decisions: MarketingConsentDecision[]
24
+ terms?: {
25
+ documents: TermsDocumentRef[]
26
+ notices?: TermsDocumentRef[]
27
+ version: string
28
+ locale?: string
29
+ acceptedAt: string
30
+ }
31
+ gpc?: boolean
32
+ createdAt: string
33
+ updatedAt: string
34
+ }
35
+
36
+ /**
37
+ * Append-only evidence of every decision and terms acceptance ever recorded — GDPR Art. 7(1)
38
+ * "demonstrate consent" material. Never updated or deleted, including on `purge()`: only the
39
+ * current-state record is cleared there.
40
+ */
41
+ export interface MarketingConsentLogRecord extends ResourceRecord {
42
+ id: string
43
+ subject: string
44
+ userId: string
45
+ profileId?: string
46
+ entityId?: string
47
+ kind: 'consent' | 'terms'
48
+ /** Present when `kind === 'consent'`. */
49
+ key?: string
50
+ granted?: boolean
51
+ revisedAt?: string
52
+ mode?: MarketingConsentMode
53
+ /** Present when `kind === 'terms'`. */
54
+ documents?: TermsDocumentRef[]
55
+ notices?: TermsDocumentRef[]
56
+ version?: string
57
+ decidedAt: string
58
+ source: MarketingConsentSource
59
+ locale?: string
60
+ gpc?: boolean
61
+ }
62
+
63
+ // `@owlmeans/marketing-consent`'s own `TermsDocumentRefSchema` (in its `src/schemas.ts`) is not
64
+ // exported, so the shape is re-declared here rather than imported.
65
+ const TermsDocumentRefSchema: JSONSchemaType<TermsDocumentRef> = {
66
+ type: 'object',
67
+ properties: {
68
+ key: { type: 'string', minLength: 1, maxLength: 128 },
69
+ href: { type: 'string', minLength: 1, maxLength: 2048 },
70
+ revisedAt: { type: 'string', nullable: true },
71
+ },
72
+ required: ['key', 'href'],
73
+ additionalProperties: false,
74
+ }
75
+
76
+ const MarketingConsentDecisionSchema: JSONSchemaType<MarketingConsentDecision> = {
77
+ type: 'object',
78
+ properties: {
79
+ key: { type: 'string', minLength: 1, maxLength: 64 },
80
+ granted: { type: 'boolean' },
81
+ revisedAt: { type: 'string', minLength: 1 },
82
+ mode: { type: 'string', enum: ['opt-in', 'opt-out'] },
83
+ decidedAt: { type: 'string', minLength: 1 },
84
+ source: { type: 'string', enum: ['sign-in', 'settings', 'cookie', 'api'] },
85
+ },
86
+ required: ['key', 'granted', 'revisedAt', 'mode', 'decidedAt', 'source'],
87
+ additionalProperties: false,
88
+ }
89
+
90
+ /**
91
+ * Shared with the Mongo/Postgres extension packages this schema is built for — imported by them,
92
+ * never duplicated.
93
+ */
94
+ export const MarketingConsentStateSchema: JSONSchemaType<MarketingConsentStateRecord> = {
95
+ type: 'object',
96
+ properties: {
97
+ id: { type: 'string', minLength: 1 },
98
+ subject: { type: 'string', minLength: 1 },
99
+ userId: { type: 'string', minLength: 1 },
100
+ profileId: { type: 'string', nullable: true },
101
+ entityId: { type: 'string', nullable: true },
102
+ decisions: { type: 'array', items: MarketingConsentDecisionSchema },
103
+ terms: {
104
+ type: 'object',
105
+ nullable: true,
106
+ properties: {
107
+ documents: { type: 'array', items: TermsDocumentRefSchema },
108
+ notices: { type: 'array', items: TermsDocumentRefSchema, nullable: true },
109
+ version: { type: 'string', minLength: 1 },
110
+ locale: { type: 'string', nullable: true },
111
+ acceptedAt: { type: 'string', minLength: 1 },
112
+ },
113
+ required: ['documents', 'version', 'acceptedAt'],
114
+ additionalProperties: false,
115
+ },
116
+ gpc: { type: 'boolean', nullable: true },
117
+ createdAt: { type: 'string', minLength: 1 },
118
+ updatedAt: { type: 'string', minLength: 1 },
119
+ },
120
+ required: ['id', 'subject', 'userId', 'decisions', 'createdAt', 'updatedAt'],
121
+ additionalProperties: false,
122
+ }
123
+
124
+ export const MarketingConsentLogSchema: JSONSchemaType<MarketingConsentLogRecord> = {
125
+ type: 'object',
126
+ properties: {
127
+ id: { type: 'string', minLength: 1 },
128
+ subject: { type: 'string', minLength: 1 },
129
+ userId: { type: 'string', minLength: 1 },
130
+ profileId: { type: 'string', nullable: true },
131
+ entityId: { type: 'string', nullable: true },
132
+ kind: { type: 'string', enum: ['consent', 'terms'] },
133
+ key: { type: 'string', nullable: true },
134
+ granted: { type: 'boolean', nullable: true },
135
+ revisedAt: { type: 'string', nullable: true },
136
+ mode: { type: 'string', enum: ['opt-in', 'opt-out'], nullable: true },
137
+ documents: { type: 'array', items: TermsDocumentRefSchema, nullable: true },
138
+ notices: { type: 'array', items: TermsDocumentRefSchema, nullable: true },
139
+ version: { type: 'string', nullable: true },
140
+ decidedAt: { type: 'string', minLength: 1 },
141
+ source: { type: 'string', enum: ['sign-in', 'settings', 'cookie', 'api'] },
142
+ locale: { type: 'string', nullable: true },
143
+ gpc: { type: 'boolean', nullable: true },
144
+ },
145
+ required: ['id', 'subject', 'userId', 'kind', 'decidedAt', 'source'],
146
+ additionalProperties: false,
147
+ }