@_linked/translation 0.5.0 → 0.7.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.
package/src/package.ts CHANGED
@@ -1,11 +1,8 @@
1
1
  import { linkedPackage } from '@_linked/core/utils/Package';
2
2
 
3
- // Reusable LINKED translation package. Uses a HOST-NEUTRAL identifier root
4
- // (NOT CN's id.create.now), so the shape/ontology IRIs baked into every app's
5
- // dataset stay stable when this package is later extracted to its own repo
6
- // (`@_linked/translation` / `@semantu/translation`) — a move, not a rewrite
7
- // (Plan 014 AD-G). R14: confirm ownership of `id.linked.cm` before seeding real
8
- // data under these IRIs; changing the baseUri afterwards means re-materializing.
3
+ // First-party LINKED package: no `baseUri`, so core's default root applies and
4
+ // IRIs follow the public rule — shapes `https://linked.cm/shape/translation/{ShapeName}`,
5
+ // package `https://linked.cm/pkg/translation` (arch-02 "Public community").
9
6
  export const {
10
7
  linkedShape,
11
8
  linkedUtil,
@@ -14,6 +11,4 @@ export const {
14
11
  packageExports,
15
12
  packageName,
16
13
  getPackageShape,
17
- } = linkedPackage('@_linked/translation', {
18
- baseUri: 'https://id.linked.cm/translation/',
19
- });
14
+ } = linkedPackage('@_linked/translation');
@@ -1,4 +1,5 @@
1
1
  import { ShapeProvider } from '@_linked/server-utils/utils/ShapeProvider';
2
+ import { callable, internal } from '@_linked/server-utils/utils/callable';
2
3
  import type {
3
4
  GlossaryTermRecord,
4
5
  TranslationEntryRecord,
@@ -595,6 +596,8 @@ export async function advanceTranslationReleaseHotfix(data: {
595
596
  expectedSequence: number;
596
597
  hotfixSequence: number;
597
598
  manifestHash: string;
599
+ /** When given, the release must belong to this app. */
600
+ appId?: string;
598
601
  }): Promise<TranslationReleaseRecord> {
599
602
  const id = requireText(data.id, 'id');
600
603
  const current = await TranslationRelease.select((release) => [
@@ -618,6 +621,9 @@ export async function advanceTranslationReleaseHotfix(data: {
618
621
  .catch(() => null);
619
622
  const record = current ? toReleaseRecord(current) : null;
620
623
  if (!record) throw new Error(`Translation release "${id}" was not found.`);
624
+ if (data.appId && !sameAppId(record.appId, data.appId)) {
625
+ throw new Error(`Translation release "${id}" was not found.`);
626
+ }
621
627
  if (record.hotfixSequence !== data.expectedSequence) {
622
628
  throw new Error(
623
629
  `Translation release advanced from sequence ${data.expectedSequence} to ${record.hotfixSequence}.`,
@@ -634,6 +640,65 @@ export async function advanceTranslationReleaseHotfix(data: {
634
640
  };
635
641
  }
636
642
 
643
+ /**
644
+ * The app a release IRI belongs to, without a trailing `/`. Release IRIs are
645
+ * minted by `createTranslationRelease` as `<appId>/translation/release/<releaseId>`
646
+ * with one trailing `/` of the app id dropped, so an app id `…/app/` and `…/app`
647
+ * give the same release IRI. Compare with `sameAppId`.
648
+ */
649
+ function releaseAppId(id: unknown): string {
650
+ const clean = typeof id === 'string' ? id.trim() : '';
651
+ const marker = '/translation/release/';
652
+ const at = clean.lastIndexOf(marker);
653
+ if (at <= 0 || at + marker.length >= clean.length) {
654
+ throw new Error('Not a translation release id.');
655
+ }
656
+ return clean.slice(0, at);
657
+ }
658
+
659
+ /** Whether two app ids name the same app, as release IRIs see them. */
660
+ function sameAppId(left: string, right: string): boolean {
661
+ return left.replace(/\/$/, '') === right.replace(/\/$/, '');
662
+ }
663
+
664
+ /**
665
+ * The app id a release is stored under (`ofApplication`), when it is the app
666
+ * its IRI names. Falls back to the app id read from the IRI, so a missing
667
+ * release is still checked against that app.
668
+ */
669
+ async function storedReleaseAppId(id: string, fromIri: string): Promise<string> {
670
+ const row: any = await TranslationRelease.select((release) => [
671
+ release.ofApplication,
672
+ ])
673
+ .where((release) => release.equals({ id } as any))
674
+ .one()
675
+ .catch(() => null);
676
+ const stored = iri(row?.ofApplication);
677
+ return stored && sameAppId(stored, fromIri) ? stored : fromIri;
678
+ }
679
+
680
+ /** WebID of the signed-in caller; never a client-supplied author. */
681
+ function actorWebIdOf(auth: any): string | undefined {
682
+ const webId =
683
+ auth?.userAccount?.accountOf?.id ??
684
+ auth?.webId ??
685
+ auth?.userAccount?.id ??
686
+ auth?.userAccount;
687
+ return typeof webId === 'string' && webId ? webId : undefined;
688
+ }
689
+
690
+ /**
691
+ * An error the LINKED server answers with `status` instead of 500. It is
692
+ * matched by shape (`name` + numeric `status`), as `ServerCallError.is` does,
693
+ * so no newer `@_linked/server-utils` is needed to throw one.
694
+ */
695
+ function translationCallError(status: number, message: string): Error {
696
+ const error = new Error(message) as Error & { status: number };
697
+ error.name = 'ServerCallError';
698
+ error.status = status;
699
+ return error;
700
+ }
701
+
637
702
  function requireText(value: string, label: string): string {
638
703
  const clean = value?.trim();
639
704
  if (!clean) throw new Error(`${label} is required.`);
@@ -1426,67 +1491,93 @@ export class TranslationProvider extends ShapeProvider {
1426
1491
 
1427
1492
  /**
1428
1493
  * Authoring gate. Reads `linkedAuth` off the request the LINKED server already
1429
- * populates — framework-level, so this stays portable (no Create Now
1430
- * import, AD-G). Anonymous callers can no longer read or write an app's
1431
- * translation content via `/call`. Fine-grained "is translation enabled for
1432
- * this app + is the caller a project member" gating is a Create Now concern and
1433
- * is enforced additionally at the CN route layer (`isTranslationEnabledForApp`).
1434
- * `getMessages` intentionally stays public — it is the app's runtime string
1435
- * fetch, not an authoring surface.
1494
+ * populates, so this stays portable (no Create Now import, AD-G).
1495
+ *
1496
+ * Every method except `getMessages` resolves the caller once, synchronously,
1497
+ * before its first `await`, and uses that value for both the permission check
1498
+ * and the recorded author. Reading the request again after an `await` could
1499
+ * see a different call's request on this shared provider instance.
1500
+ *
1501
+ * Whether the caller may act on `appId` is decided by the host's resolver
1502
+ * (`configureTranslationAuthorization`); with none configured, every
1503
+ * authoring and read action is denied. `getMessages` stays public: it is the
1504
+ * app's runtime string fetch, not an authoring surface.
1505
+ *
1506
+ * `requireActor` and `requireAccess` are `@internal()`: helpers of the
1507
+ * methods below, never dispatched over HTTP.
1436
1508
  */
1437
- private requireAuthor(): void {
1438
- const account = (this as any).request?.linkedAuth?.userAccount;
1439
- if (!account) {
1440
- throw new Error('Authentication required to read or edit translations.');
1509
+ @internal()
1510
+ private requireActor(): string {
1511
+ const auth = (this as any).request?.linkedAuth;
1512
+ if (!auth?.userAccount) {
1513
+ throw translationCallError(
1514
+ 401,
1515
+ 'Authentication required to read or edit translations.',
1516
+ );
1441
1517
  }
1518
+ const webId = actorWebIdOf(auth);
1519
+ if (!webId) {
1520
+ throw new Error('Translation permission required.');
1521
+ }
1522
+ return webId;
1442
1523
  }
1443
1524
 
1525
+ @internal()
1444
1526
  private async requireAccess(
1527
+ actorWebId: string,
1445
1528
  data: { appId: string; language?: string },
1446
1529
  action: TranslationAuthoringAction,
1447
1530
  ): Promise<void> {
1448
- this.requireAuthor();
1449
- const actorWebId = this.authorIri();
1531
+ const appId = typeof data?.appId === 'string' ? data.appId.trim() : '';
1532
+ if (!appId) throw new Error('appId is required.');
1450
1533
  if (
1451
- !actorWebId ||
1452
1534
  !(await canAuthorTranslation({
1453
- appId: data.appId,
1535
+ appId,
1454
1536
  actorWebId,
1455
1537
  action,
1456
- language: data.language,
1538
+ language: data?.language,
1457
1539
  }))
1458
1540
  ) {
1459
1541
  throw new Error(`Translation ${action} permission required.`);
1460
1542
  }
1461
1543
  }
1462
1544
 
1545
+ @callable('user')
1463
1546
  async listKeys(data: { appId: string }) {
1464
- this.requireAuthor();
1547
+ const actor = this.requireActor();
1548
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1465
1549
  return listTranslationKeys(data);
1466
1550
  }
1467
1551
 
1552
+ @callable('user')
1468
1553
  async listEntries(data: { appId: string }): Promise<TranslationEntryRecord[]> {
1469
- this.requireAuthor();
1554
+ const actor = this.requireActor();
1555
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1470
1556
  return listTranslationEntries(data);
1471
1557
  }
1472
1558
 
1559
+ @callable('user')
1473
1560
  async listKeyVersions(data: {
1474
1561
  appId: string;
1475
1562
  key?: string;
1476
1563
  }): Promise<TranslationKeyVersionRecord[]> {
1477
- this.requireAuthor();
1564
+ const actor = this.requireActor();
1565
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1478
1566
  return listTranslationKeyVersions(data);
1479
1567
  }
1480
1568
 
1569
+ @callable('user')
1481
1570
  async listMemoryMatches(data: {
1482
1571
  appId: string;
1483
1572
  key: string;
1484
1573
  language: string;
1485
1574
  }): Promise<TranslationMemoryMatchRecord[]> {
1486
- this.requireAuthor();
1575
+ const actor = this.requireActor();
1576
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1487
1577
  return listTranslationMemoryMatches(data);
1488
1578
  }
1489
1579
 
1580
+ @callable('user')
1490
1581
  async proposeRevision(data: {
1491
1582
  appId: string;
1492
1583
  key: string;
@@ -1495,7 +1586,8 @@ export class TranslationProvider extends ShapeProvider {
1495
1586
  basedOnText?: string;
1496
1587
  note?: string;
1497
1588
  }): Promise<{ id: string }> {
1498
- await this.requireAccess(data, 'propose');
1589
+ const actor = this.requireActor();
1590
+ await this.requireAccess(actor, data, 'propose');
1499
1591
  const currentEntries = await listTranslationEntries({ appId: data.appId });
1500
1592
  const basedOnText =
1501
1593
  currentEntries.find(({ key }) => key === data.key)?.units[data.language]
@@ -1504,18 +1596,18 @@ export class TranslationProvider extends ShapeProvider {
1504
1596
  ...data,
1505
1597
  basedOnText,
1506
1598
  status: 'proposed',
1507
- author: this.authorIri(),
1599
+ author: actor,
1508
1600
  authorKind: 'human',
1509
1601
  });
1510
1602
  }
1511
1603
 
1604
+ @callable('user')
1512
1605
  async listProposals(data: {
1513
1606
  appId: string;
1514
1607
  language?: string;
1515
1608
  }): Promise<TranslationRevisionRecord[]> {
1516
- this.requireAuthor();
1517
- const actorWebId = this.authorIri();
1518
- if (!actorWebId) throw new Error('Translation read permission required.');
1609
+ const actorWebId = this.requireActor();
1610
+ await this.requireAccess(actorWebId, { appId: data?.appId }, 'read');
1519
1611
  const proposals = await listTranslationProposals(data);
1520
1612
  const allowed = await Promise.all(
1521
1613
  proposals.map((proposal) =>
@@ -1530,12 +1622,15 @@ export class TranslationProvider extends ShapeProvider {
1530
1622
  return proposals.filter((_, index) => allowed[index]);
1531
1623
  }
1532
1624
 
1625
+ @callable('user')
1533
1626
  async decideProposal(data: {
1534
1627
  appId: string;
1535
1628
  revisionId: string;
1536
1629
  decision: 'accept' | 'reject';
1537
1630
  note?: string;
1538
1631
  }): Promise<TranslationProposalDecision> {
1632
+ const actor = this.requireActor();
1633
+ await this.requireAccess(actor, { appId: data.appId }, 'review');
1539
1634
  const proposal = await getTranslationRevision({
1540
1635
  appId: data.appId,
1541
1636
  revisionId: data.revisionId,
@@ -1544,85 +1639,93 @@ export class TranslationProvider extends ShapeProvider {
1544
1639
  throw new Error('This translation proposal is no longer pending.');
1545
1640
  }
1546
1641
  await this.requireAccess(
1642
+ actor,
1547
1643
  { appId: data.appId, language: proposal.language },
1548
1644
  'review',
1549
1645
  );
1550
- return decideTranslationProposal(data, this.authorIri());
1646
+ return decideTranslationProposal(data, actor);
1551
1647
  }
1552
1648
 
1649
+ @callable('user')
1553
1650
  async listMemory(data: {
1554
1651
  appId: string;
1555
1652
  }): Promise<TranslationMemoryRecord[]> {
1556
- this.requireAuthor();
1653
+ const actor = this.requireActor();
1654
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1557
1655
  return listTranslationMemory(data);
1558
1656
  }
1559
1657
 
1658
+ @callable('user')
1560
1659
  async pretranslateMemory(data: {
1561
1660
  appId: string;
1562
1661
  language: string;
1563
1662
  dryRun?: boolean;
1564
1663
  }): Promise<TranslationMemoryPretranslateReport> {
1565
- await this.requireAccess(data, 'run-mt');
1566
- return pretranslateFromMemory(data, { author: this.authorIri() });
1664
+ const actor = this.requireActor();
1665
+ await this.requireAccess(actor, data, 'run-mt');
1666
+ return pretranslateFromMemory(data, { author: actor });
1567
1667
  }
1568
1668
 
1669
+ @callable('user')
1569
1670
  async applyMemoryMatch(data: {
1570
1671
  appId: string;
1571
1672
  key: string;
1572
1673
  language: string;
1573
1674
  unitId: string;
1574
1675
  }): Promise<TranslationUnitRecord> {
1575
- await this.requireAccess(data, 'review');
1576
- return applyTranslationMemoryMatch(data, { author: this.authorIri() });
1676
+ const actor = this.requireActor();
1677
+ await this.requireAccess(actor, data, 'review');
1678
+ return applyTranslationMemoryMatch(data, { author: actor });
1577
1679
  }
1578
1680
 
1681
+ @callable('user')
1579
1682
  async listReleases(data: {
1580
1683
  appId: string;
1581
1684
  }): Promise<TranslationReleaseRecord[]> {
1582
- this.requireAuthor();
1685
+ const actor = this.requireActor();
1686
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1583
1687
  return listTranslationReleases(data);
1584
1688
  }
1585
1689
 
1690
+ @callable('user')
1586
1691
  async createRelease(
1587
1692
  data: Omit<TranslationReleaseRecord, 'id' | 'createdAt'> & {
1588
1693
  createdAt?: string;
1589
1694
  },
1590
1695
  ): Promise<TranslationReleaseRecord> {
1591
- this.requireAuthor();
1696
+ const actor = this.requireActor();
1697
+ await this.requireAccess(actor, data, 'manage');
1592
1698
  return createTranslationRelease(data);
1593
1699
  }
1594
1700
 
1701
+ @callable('user')
1595
1702
  async advanceReleaseHotfix(data: {
1596
1703
  id: string;
1597
1704
  expectedSequence: number;
1598
1705
  hotfixSequence: number;
1599
1706
  manifestHash: string;
1600
1707
  }): Promise<TranslationReleaseRecord> {
1601
- this.requireAuthor();
1602
- return advanceTranslationReleaseHotfix(data);
1708
+ const actor = this.requireActor();
1709
+ const id = typeof data?.id === 'string' ? data.id.trim() : '';
1710
+ const appId = await storedReleaseAppId(id, releaseAppId(id));
1711
+ await this.requireAccess(actor, { appId }, 'manage');
1712
+ return advanceTranslationReleaseHotfix({ ...data, appId });
1603
1713
  }
1604
1714
 
1715
+ @callable('user')
1605
1716
  async upsertKey(data: TranslationKeyInput): Promise<{ id: string }> {
1606
- this.requireAuthor();
1607
- return upsertTranslationKey(data, { createdBy: this.authorIri() });
1608
- }
1609
-
1610
- /** WebID of the signed-in caller — never trust a client-supplied author. */
1611
- private authorIri(): string | undefined {
1612
- const auth = (this as any).request?.linkedAuth;
1613
- const webId =
1614
- auth?.userAccount?.accountOf?.id ??
1615
- auth?.webId ??
1616
- auth?.userAccount?.id ??
1617
- auth?.userAccount;
1618
- return typeof webId === 'string' && webId ? webId : undefined;
1717
+ const actor = this.requireActor();
1718
+ await this.requireAccess(actor, data, 'manage');
1719
+ return upsertTranslationKey(data, { createdBy: actor });
1619
1720
  }
1620
1721
 
1722
+ @callable('user')
1621
1723
  async upsertUnit(data: TranslationUnitInput): Promise<{ id: string }> {
1622
- await this.requireAccess(data, 'review');
1724
+ const actor = this.requireActor();
1725
+ await this.requireAccess(actor, data, 'review');
1623
1726
  return upsertTranslationUnit(
1624
1727
  { ...data, updatedBy: undefined },
1625
- { author: this.authorIri(), authorKind: 'human' },
1728
+ { author: actor, authorKind: 'human' },
1626
1729
  );
1627
1730
  }
1628
1731
 
@@ -1632,11 +1735,14 @@ export class TranslationProvider extends ShapeProvider {
1632
1735
  * anchors the IRIs. Uniqueness = (term, language) — upsert is
1633
1736
  * delete-then-create on that identity.
1634
1737
  */
1738
+ @callable('user')
1635
1739
  async listGlossary(data: { appId: string }): Promise<GlossaryTermRecord[]> {
1636
- this.requireAuthor();
1740
+ const actor = this.requireActor();
1741
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1637
1742
  return listGlossaryTerms(data);
1638
1743
  }
1639
1744
 
1745
+ @callable('user')
1640
1746
  async upsertGlossaryTerm(data: {
1641
1747
  appId: string;
1642
1748
  term: string;
@@ -1647,7 +1753,8 @@ export class TranslationProvider extends ShapeProvider {
1647
1753
  caseSensitive?: boolean;
1648
1754
  description?: string;
1649
1755
  }): Promise<{ id: string }> {
1650
- await this.requireAccess(data, 'manage');
1756
+ const actor = this.requireActor();
1757
+ await this.requireAccess(actor, data, 'manage');
1651
1758
  const appId = requireText(data.appId, 'appId');
1652
1759
  const term = requireText(data.term, 'term');
1653
1760
  const language = data.language?.trim() || '';
@@ -1678,8 +1785,10 @@ export class TranslationProvider extends ShapeProvider {
1678
1785
  return { id };
1679
1786
  }
1680
1787
 
1788
+ @callable('user')
1681
1789
  async deleteGlossaryTerm(data: { appId: string; id: string }): Promise<{ deleted: boolean }> {
1682
- await this.requireAccess(data, 'manage');
1790
+ const actor = this.requireActor();
1791
+ await this.requireAccess(actor, data, 'manage');
1683
1792
  const appId = requireText(data.appId, 'appId');
1684
1793
  const id = requireText(data.id, 'id');
1685
1794
  // Only glossary nodes under this app's namespace may be deleted here.
@@ -1691,12 +1800,14 @@ export class TranslationProvider extends ShapeProvider {
1691
1800
  }
1692
1801
 
1693
1802
  /** History for one (key, language) cell — newest first (Plan 017 AD-P). */
1803
+ @callable('user')
1694
1804
  async listRevisions(data: {
1695
1805
  appId: string;
1696
1806
  key: string;
1697
1807
  language: string;
1698
1808
  }): Promise<TranslationRevisionRecord[]> {
1699
- this.requireAuthor();
1809
+ const actor = this.requireActor();
1810
+ await this.requireAccess(actor, { appId: data?.appId }, 'read');
1700
1811
  const appId = requireText(data.appId, 'appId');
1701
1812
  const key = requireText(data.key, 'key');
1702
1813
  const language = requireText(data.language, 'language');
@@ -1721,6 +1832,7 @@ export class TranslationProvider extends ShapeProvider {
1721
1832
  return toRevisionRecords(rows as any);
1722
1833
  }
1723
1834
 
1835
+ @callable('public')
1724
1836
  async getMessages(data: {
1725
1837
  appId: string;
1726
1838
  language: string;